代码 Stripe 拒付处理:收到 charge.dispute.created 后,先看 evidence_details.due_by

2026-09-27 09:00:55

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:没有分类的默认值,尽量把能填的都填上。

两个能省力的点:

  1. 如果你在付款时通过 Payment Intent 把商品描述、账单地址等信息传给了 Stripe,Stripe 会自动预填这些证据字段。预填的字段不要改,改了可能影响 Visa CE 3.0 的资格判定。
  2. 对欺诈类拒付,若命中 liability shift(责任转移)规则,Stripe 会自动带入 3DS 的 ECI 等信息,这类案子赢面更高。

反过来说,能不能赢很大程度上取决于付款那一刻你收集了什么。等 dispute 来了才想起没有日志、没有 IP、没有取消政策,基本只能认赔。所以证据留存要前置到下单链路。

实操清单

  1. webhook 加 charge.dispute.* 事件订阅,收到先验签,再异步处理,别在回调里直接改订单状态。
  2. 收到 charge.dispute.created 立刻查 dispute,把 id / amount / charge / payment_intent / reason / status / evidence_details.due_by 落库,按 due_by 设一个内部提醒任务。
  3. 判断能否用 is_charge_refundable 主动退款止损;但要意识到退款不等于撤销 dispute。
  4. 按 reason 组装证据,文件走 purpose=dispute_evidence 上传,先 submit=false 暂存自检,再 submit=true。
  5. 把 charge.dispute.closed / funds_reinstated / funds_withdrawn 接全,保证资金流水和 dispute 状态都能对上。

未实测与免责

本文的字段、状态值、上限(150,000 字符 / 20,000 单字段 / 4.5 MB / 19 页)和事件名,均来自 Stripe 官方文档整理,未在真实 Stripe 账号上跑过一遍拒付全流程。各卡组织、各地区账户的具体规则和时限会变,落地前以你账号后台的实际字段和官方文档为准。

参考文档:

推荐文章

程序员茄子在线接单