Stripe 拒付处理:due_by=0 不是今天到期,证据只交一次就锁死
文档:How disputes work、Respond to disputes、Use the API to respond to disputes、Dispute object,API 版本 2026-09-30.endive,字段以账号版本为准。
拒付不是退款,accept 和 counter 是两条路
持卡人向发卡行提出争议后,卡组织会先从商户的 Stripe 余额扣走争议金额 + 争议费,这笔钱在整个争议期间被冻结。此时只有两个动作:在期限内提交证据(counter,抗辩),或者接受(accept,确认不抗辩)。抗辩要额外付 countered fee,而且只有一次机会。
争议开放期间,不能对这笔 payment 发起普通退款。文档原话是 You can't issue a refund outside the dispute process while the dispute is open——想退钱只能 accept,is_charge_refundable 只在特定条件下为 true。
inquiry(预警)常被当成小事。部分卡组织(Amex、Discover 常见,Mastercard/Visa 已不用)在正式 chargeback 前先发 inquiry,API 里叫 warning,状态是 warning_needs_response / warning_under_review / warning_closed。这个阶段应答不收争议费,但不应答可能升级成正式 chargeback,而且多半打不赢;Discover 的 inquiry 未应答会直接变成不可抗辩争议。Dashboard 里点 accept inquiry 不等于解决争议,必须提交证据。warning 挂 120 天未升级,Stripe 标记 closed。
时限上,卡组织一般允许持卡人在原交易后 120 天内发起争议(旅行、演出票等从活动日开始算)。chargeback 创建后有 7–21 天应答窗口(取决于卡组织),提交证据后发卡行裁决 60–75 天,全流程 2–3 个月,无法加速(除非 accept)。
状态机与两个事件
状态枚举:warning_needs_response、warning_under_review、warning_closed、needs_response、under_review、won、lost、prevented。prevented 表示在变成正式 chargeback 之前被阻止。
需要落库的字段:id(du_ 开头)、charge、payment_intent(nullable)、amount、currency、reason、status、evidence{}、evidence_details{due_by, has_evidence, past_due, submission_count}、balance_transactions(0–2 笔,对应扣款与返还)、is_charge_refundable、payment_method_details.card.case_type(chargeback / inquiry / compliance / resolution / block)、network_reason_code。
reason 枚举:fraudulent、product_not_received、product_unacceptable、subscription_canceled、duplicate、credit_not_processed、unrecognized、general、customer_initiated、bank_cannot_process、check_returned、debit_not_authorized、incorrect_account_details、insufficient_funds、noncompliant。
事件只有两个要处理:charge.dispute.created(创建时)、charge.dispute.closed(裁决后,status 变 won/lost)。Dispute 对象不直接引用 invoice/subscription,要自己通过 charge 或 payment_intent 关联到订单。另有极少数 late win:lost 之后又变 won,由发卡行驱动,Stripe 无法预测,所以别把 lost 当成终态写死业务逻辑。
五个容易写错的地方
due_by=0 不是今天到期。 due_by 是 Unix 时间戳;发卡行不允许应答时该字段为 0(文档:Will be 0 if the customer's bank or credit card company doesn't allow a response for this particular dispute)。当成“今天到期”会天天误报,当成“已过期”会漏掉一批不可应答的争议。
证据只有一次机会,且更新任一字段会全量覆盖。 submission_count 通常是 1,提交后不能修改、不能补交。更新 evidence hash 里任一字段,会把 hash 里所有字段一起提交给银行。文本类证据合计上限 150,000 字符。文件类字段(customer_communication、service_documentation、receipt、refund_policy、shipping_documentation、customer_signature、cancellation_policy、duplicate_charge_documentation、uncategorized_file)要先用 File Upload API 上传,purpose=dispute_evidence,拿到 file_upload id 再填。文件合计上限 4.5 MB,Mastercard 合计 19 页。银行不会看外链、音视频,也不会看“请联系客服”的说明。
暂存不等于安全。 evidence[submit]=false 时证据只暂存在 dispute 上,不送银行,之后再发一次 submit=true 才真正提交;默认是 submit=true。所以第一次写入就要显式带 false。更要命的是:暂存后再次更新,整个 hash 会重新提交——等于把没写完的证据送出去了。
争议期间不能自行退款。 见上文,想退只能 accept。
多笔争议按 dispute id 建表。 一笔 payment 可能有多笔 dispute:不同 reason code、不同商品行、多次 capture。拿 charge id 当主键会直接覆盖。争议金额也不一定等于原交易金额:汇率换算、订阅多笔合并成一笔、部分争议、部分退款后再全额争议,都会让两者对不上。
Go:webhook 幂等落库与到期扫描
下面两段是思路示意,未实测,验签和错误处理按线上补齐。核心是唯一键 dispute.id,重复事件走 UPDATE 而不是 INSERT。
// 未实测
const upsertDispute = `
INSERT INTO disputes (id, charge_id, payment_intent, amount, currency, reason,
status, due_by, has_evidence, submission_count, event_created)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status,
due_by = EXCLUDED.due_by,
has_evidence = EXCLUDED.has_evidence,
submission_count = EXCLUDED.submission_count,
event_created = EXCLUDED.event_created
WHERE disputes.event_created < EXCLUDED.event_created` // 防乱序重投回退状态
func handleDisputeWebhook(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 验签省略:stripe.Webhook.ConstructEvent(payload, sigHeader, secret)
var ev struct {
ID string `json:"id"`
Type string `json:"type"`
Created int64 `json:"created"`
Data struct {
Object struct {
ID string `json:"id"`
Charge string `json:"charge"`
PaymentIntent *string `json:"payment_intent"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
Reason string `json:"reason"`
Status string `json:"status"`
EvidenceDetails struct {
DueBy int64 `json:"due_by"`
HasEvidence bool `json:"has_evidence"`
SubmissionCount int `json:"submission_count"`
} `json:"evidence_details"`
} `json:"object"`
} `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&ev); err != nil {
http.Error(w, "bad payload", 400)
return
}
if ev.Type != "charge.dispute.created" && ev.Type != "charge.dispute.closed" {
w.WriteHeader(200)
return
}
o := ev.Data.Object
_, err := db.ExecContext(r.Context(), upsertDispute,
o.ID, o.Charge, o.PaymentIntent, o.Amount, o.Currency, o.Reason,
o.Status, o.EvidenceDetails.DueBy, o.EvidenceDetails.HasEvidence,
o.EvidenceDetails.SubmissionCount, ev.Created)
if err != nil {
http.Error(w, "db error", 500) // 返回非 2xx 让 Stripe 重投
return
}
w.WriteHeader(200)
}
}
扫描任务只捞仍可应答的状态,按剩余时间分档,due_by=0 单独归类。
// 未实测:假设有一个 alert(disputeID, level) 打到你们的告警通道
func scanDueDisputes(ctx context.Context, db *sql.DB) error {
rows, err := db.QueryContext(ctx, `
SELECT id, due_by FROM disputes
WHERE status IN ('needs_response','warning_needs_response')`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id string
var dueBy int64
if err := rows.Scan(&id, &dueBy); err != nil {
return err
}
if dueBy == 0 {
alert(id, "no_response_allowed") // 不可应答,走人工判断是否 accept
continue
}
left := time.Until(time.Unix(dueBy, 0))
switch {
case left <= 24*time.Hour:
alert(id, "due_24h")
case left <= 72*time.Hour:
alert(id, "due_72h")
}
}
return rows.Err()
}
小时级 cron 跑一次,同一 dispute 同一档位只告警一次(记一张已发告警表或内存去重)。
费用与对账
争议接收费(dispute received fee)基本不退,赢了也不退;墨西哥例外,赢了或持卡人撤诉可能退。抗辩费(dispute countered fee)赢了才退;墨西哥、日本不收 countered fee。Visa/Mastercard compliance 争议如果抗辩,另收 500 USD(或等值)网络费,赢了退。
对账时看 charge 上的 balance_transactions,0–2 笔分别对应扣款和返还;won 是发卡行把本金退回 Stripe 再退给商户,lost 钱不回来。拒付率还会进卡组织的监控计划(monitoring program),过高会被罚,甚至进 MATCH 一类名单。
预防:EFW 阈值、3DS 和 Visa CE 3.0
EFW(early fraud warning,Visa TC40、Mastercard SAFE)不要求应答,但约 40% 的 Visa/Mastercard EFW 会变成欺诈争议。官方分析给的口径是:金额约等于争议费及以下时退款划算,金额超过争议费 35% 就不值得退;无差别全退不是好策略。另外,主动退款并不能消除欺诈报告,除非在 capture 后 2 小时内作为 reversal 处理。
3DS liability shift 覆盖的欺诈争议,Stripe 会自动补 ECI 等证据,不用手动填。Visa CE 3.0(针对 10.4 card absent fraud)会自动判定资格,预填字段不要改,改了可能丢资格。
哪些场景不适用
LPM(Klarna、PayPal)有自己的争议流程:裁决方是服务商而不是发卡行,窗口通常 180 天,费用结构不同,证据要求也不同,不能套用上面这套状态机。
不可抗辩的情况要知道:Cartes Bancaires 网络(仅 SEPA 商户)、尼日利亚本地支付方式;Discover 的 inquiry 未应答也会变成不可抗辩。争议结果是终局,Stripe 不支持仲裁(arbitration)阶段——所以任何想做“二次申诉”“升级仲裁”的工单系统,都不要建在 Stripe 的 dispute 上。