Stripe 收到 charge.dispute.created 之后,先看 evidence_details.due_by
先把三件事分清楚:退款、投诉、拒付
做国内支付久了容易形成惯性:钱不对,发起退款就行。但 Stripe 上的 dispute(拒付 / chargeback)不是退款,它不是你主动发起的,而是持卡人绕过你、直接向发卡行申诉后,钱被银行强制划走。
- 退款(refund):你发起,钱退回用户,订单转退款态。
- 拒付(dispute):用户向发卡行发起,银行先从你的 Stripe 余额扣钱,再给你一个申诉窗口。赢了你拿回钱,输了这笔钱和一笔拒付手续费都没了。
- 国内微信支付 / 支付宝没有 chargeback 这套机制,对应的是「消费者投诉 / 交易异常」,处理逻辑完全不同(站内 #7496 写过交易异常的分层定位)。
所以第一件容易踩的坑:把 dispute 当退款处理,直接给用户退钱。钱是退了,但 dispute 还在走流程,你等于把同一笔钱赔了两遍。
事件来了先别动,先把 dispute 对象查出来
拒付通过 webhook 推送,核心事件是 charge.dispute.created。收到后先照常做验签(和 Stripe 其它 webhook 一样,raw body + stripe-signature),别急着改订单状态。
然后用 dispute_id 查详情:
curl https://api.stripe.com/v1/disputes/{{DISPUTE_ID}} \
-u ">:"
返回对象里几个必须看的字段:
{
"id": "du_1MtJUT2eZvKYlo2CNaw2HvEv",
"object": "dispute",
"amount": 1000,
"charge": "ch_1AZtxr2eZvKYlo2CJDX8whov",
"currency": "usd",
"reason": "general",
"status": "warning_needs_response",
"payment_intent": null,
"evidence_details": {
"due_by": 1682294399,
"has_evidence": false,
"past_due": false,
"submission_count": 0
},
"is_charge_refundable": true
}
amount是争议金额,单位仍是货币最小单位(USD 的 1000 = $10.00,和站内 #7456 讲的 Stripe minor unit 一致)。charge/payment_intent是你把它关联回本地订单的钥匙,但注意payment_intent可能为null,别写死只认一个。evidence_details.due_by是证据提交截止时间,Unix 秒。这是整篇最重要的一行,过了这个点就基本默认输。is_charge_refundable告诉你这笔还能不能主动退款止损。
status 是一条状态机,不是布尔值
dispute 的状态会变,webhook 也会反复推。别用一个 is_disputed=true 了事:
warning_needs_response:预警阶段(部分卡组织支持,如 Visa 的 "early fraud warning"),此时钱可能还没扣,可以提前提交证据或退款。needs_response:正式拒付,钱已被扣,等你交证据。under_review:证据已提交,发卡行在审。won/lost:出结果。warning_closed:预警未处理而关闭。
对应的资金事件也要单独接:
charge.dispute.created:客户发起拒付。charge.dispute.funds_withdrawn:钱被划走。charge.dispute.updated:dispute 被更新(通常是补了证据)。charge.dispute.closed:结案,状态落到won/lost/warning_closed。charge.dispute.funds_reinstated:赢了之后钱退回(含部分退款的场景)。
工程上建议:把 dispute 当作订单的一个独立状态维度(dispute_status),和「订单状态」「退款状态」分开存,和站内 #7510「支付状态与订单状态分离」是同一个思路。
证据只能提交一次,别指望改了再传
这是 Stripe 文档里写得很直白、但最容易被忽略的一条:你只有一次提交机会。提交后 Stripe 立刻把响应和所有附件转给发卡行,之后不能修改、不能补交。所以要把证据凑齐再提交,或者用暂存。
提交走 UPDATE dispute 接口:
curl https://api.stripe.com/v1/disputes/{{DISPUTE_ID}} \
-u ">:" \
--data-urlencode "evidence[customer_email_address]=email@example.com" \
-d "evidence[shipping_date]=2024-02-01" \
-d "evidence[shipping_documentation]={{FILE_ID}}" \
-d "submit=false"
submit=false:把证据暂存到 dispute 上,API 和 Dashboard 都能看到,但不提交给银行;确认无误后再发一次请求、把submit设为true(默认值就是 true)。- 规则上有个坑:只要更新了 evidence 里任一字段,这个 hash 里的所有字段会被整体提交审核。也就是说不能只改一个字段而不带上其它已填内容。
证据分两类:
- 文本类:如
customer_email_address、service_date、uncategorized_text。所有文本字段合计上限 150,000 字符;单个字段(如access_activity_log、cancellation_rebuttal)上限 20,000。 - 文件类:如
service_documentation、customer_communication、duplicate_charge_documentation,值填的是 File Upload 的 ID。
文件先走 File Upload,purpose=dispute_evidence,拿到 file_upload 对象 ID 再填进 evidence。文档给的硬限制:证据附件合计最大 4.5 MB;Mastercard 的证据合计最多 19 页。每种证据类型只能传一个文件,多个文件要自己合并成一个多页文件。
还有一条:评估银行不会去看外部内容。所以别放音频视频、别放「打这个电话/点这个链接获取更多信息」——放进去等于没放。
按 reason 选证据,别一套材料打天下
reason 决定你要交什么证据,常见取值和对应材料:
fraudulent(欺诈):access_activity_log(客户确实访问/下载了商品的服务器日志,需带 IP 和时间戳)、customer_email_address、customer_purchase_ip、3DS 认证信息。duplicate(重复扣款):duplicate_charge_id(原交易的 charge ID)、duplicate_charge_documentation,以及证明两笔是不同交易的说明。product_not_received/service_not_received(未收到货/服务):shipping_tracking_number、shipping_documentation、service_date、service_documentation。subscription_canceled(已取消订阅仍扣款):cancellation_policy、cancellation_policy_disclosure、cancellation_rebuttal、customer_communication。general:没有分类的默认值,尽量把能填的都填上。
两个能省力的点:
- 如果你在付款时通过 Payment Intent 把商品描述、账单地址等信息传给了 Stripe,Stripe 会自动预填这些证据字段。预填的字段不要改,改了可能影响 Visa CE 3.0 的资格判定。
- 对欺诈类拒付,若命中 liability shift(责任转移)规则,Stripe 会自动带入 3DS 的 ECI 等信息,这类案子赢面更高。
反过来说,能不能赢很大程度上取决于付款那一刻你收集了什么。等 dispute 来了才想起没有日志、没有 IP、没有取消政策,基本只能认赔。所以证据留存要前置到下单链路。
实操清单
- webhook 加
charge.dispute.*事件订阅,收到先验签,再异步处理,别在回调里直接改订单状态。 - 收到
charge.dispute.created立刻查 dispute,把id / amount / charge / payment_intent / reason / status / evidence_details.due_by落库,按due_by设一个内部提醒任务。 - 判断能否用
is_charge_refundable主动退款止损;但要意识到退款不等于撤销 dispute。 - 按
reason组装证据,文件走purpose=dispute_evidence上传,先submit=false暂存自检,再submit=true。 - 把
charge.dispute.closed/funds_reinstated/funds_withdrawn接全,保证资金流水和 dispute 状态都能对上。
未实测与免责
本文的字段、状态值、上限(150,000 字符 / 20,000 单字段 / 4.5 MB / 19 页)和事件名,均来自 Stripe 官方文档整理,未在真实 Stripe 账号上跑过一遍拒付全流程。各卡组织、各地区账户的具体规则和时限会变,落地前以你账号后台的实际字段和官方文档为准。
参考文档: