Stripe 订阅续费扣款失败:催缴锚点选 invoice.payment_failed,不是 charge.failed
问题场景
SaaS 订阅制,用户信用卡到期或额度不足,续费扣款失败。Stripe 不会替你通知用户,也不会自动降权。
如果系统只监听 payment_intent.succeeded / charge.failed,结果通常是两种:扣款失败但用户 access 照旧保留,等于白嫖;或者通知重复发、access 状态错乱。这两种都不好排查,因为问题不在支付本身,而在你选的催缴锚点。
前提:下面讨论的是 Stripe Billing 的订阅模式(invoice 驱动),订阅的催缴、重试、恢复都围绕 invoice 事件走。如果你只是用 PaymentIntent 收一次性款,或者自己维护周期扣款逻辑、不走 Billing 的 invoice,本文的事件与字段不适用。
事件锚点
以下事件口径来自官方文档(docs.stripe.com/billing/subscriptions/webhooks 与 smart-retries):
invoice.payment_failed:订阅催缴/recovery 的正确锚点。data.object是 invoice,带 invoice + subscription 上下文,以及attempt_count、next_payment_attempt。invoice.paid:付款成功后恢复访问权限、清掉催缴状态的事件。invoice.updated:付款成功或失败都会发;成功时paid=true、status=paid;失败时paid=false、status仍为open。失败同时会触发invoice.payment_failed。charge.failed:更底层的产物,不是订阅 recovery 的推荐触发点。社区经验(rexautomaton.com)明确建议不要拿charge.failed当订阅催缴锚点,会缺 subscription 上下文。invoice.finalization_failed:如果账单无法定稿,会发这个事件。账单未定稿就无法收款,但订阅仍是 active——会出现用户还能用、你却收不到钱的空窗,必须处理。invoice.payment_action_required:需要用户 3DS 验证时发。要取 PaymentIntent 的client_secret走confirmCardPayment让用户补验证。
关键字段
attempt_count:invoice.payment_failed里,表示到目前为止已尝试的次数。next_payment_attempt:invoice 上表示下次收款时间。这里有个坑(官方 Warning):使用 automations 的用户,next_payment_attempt不再出现在invoice.payment_failed,而是出现在invoice.updated。所以调度催缴要同时订阅invoice.payment_failed和invoice.updated。payment_intent.last_payment_error/invoice.last_payment_error:拿发卡行的decline_code和type,据此区分硬拒绝/软拒绝。
硬拒绝 vs 软拒绝
官方口径。硬拒绝码(Stripe 不会自动重试):
incorrect_number
lost_card
pickup_card
stolen_card
revocation_of_authorization
revocation_of_all_authorizations
authentication_required
highest_risk_level
transaction_not_allowed
硬拒绝时的行为:排期的重试仍会继续、attempt_count 仍会递增,但只有在检测到新的支付方式后重试才会真正执行;未执行的重试不会产生新的 Charge。
另外这些情况 Stripe 也不会重试:没有可用支付方式;发卡行返回硬拒绝码;卡是印度发行的(India-issued);Stripe Connect 账户已断开。
软拒绝(如余额不足 insufficient_funds):可以自动重试。
重试策略
官方口径:
- Smart Retries:用 AI 选最佳重试时间;按次数 + 最长时长配置,周期可选 1 周 / 2 周 / 3 周 / 1 个月 / 2 个月;官方推荐默认 8 tries within 2 weeks(2 周内 8 次)。
- 自定义重试:最多配置 3 次重试,每次指定距上次的重试天数。
- 本地支付方式重试(默认不开,需显式开启;开启也可能失败,Stripe 不承担损失):
- ACH Direct Debit:仅
insufficient_funds可重试,最多 2 次,最长 40 天。 - ACSS Direct Debit:最多 1 次,30 天。
- Australia BECS Direct Debit:最多 4 次,30 天。
- Bacs Direct Debit:最多 2 次,30 天。
- New Zealand BECS Direct Debit:最多 1 次,30 天。
- SEPA Direct Debit:最多 2 次,30 天。
- ACH Direct Debit:仅
恢复失败后订阅怎么走
官方口径,三选一:
- Cancel the subscription:达到重试计划最大天数后变
canceled。 - Mark as unpaid:变
unpaid,之后仍继续生成 invoice 但保持draft。 - Leave past-due:保持
past_due,继续生成 invoice 并按重试设置扣客户。
最终一次尝试之后,Stripe 不再做任何支付尝试;改订阅设置只影响未来的重试。
支付方式选择顺序
官方口径,重试时按此顺序取第一个可用支付方式:
subscription.default_payment_methodsubscription.default_sourcecustomer.invoice_settings.default_payment_method(Customer 对象)/configuration.customer.billing.default_payment_method(customer-configured Account 对象)- legacy
customer.default_source
坑:扣款失败后更新支付方式,必须更新当初失败的那个字段。例如订阅有 default_payment_method,你只更新了 customer.invoice_settings.default_payment_method,Stripe 仍会继续用订阅的 default_payment_method 重试。
Webhook 投递语义
官方口径:
- live 模式:端点响应不对,Stripe 以指数退避持续重试最长 3 天;sandbox 模式几次几小时内重试 3 次。
- 至少一次投递:会有重复,也会乱序(官方明说不保证顺序)。必须先验签,再按
event.id幂等去重,只做持久化写入后再回 2xx,重活丢异步队列。官方要求:在执行任何可能超时的复杂逻辑之前,先快速返回 2xx。
站内已有经验(#7362 用户付一次钱,系统入账两次):去重记录要持久化、唯一约束要防 NULL、并发靠 DB 约束兜底,别先查后插。
落地清单
- 订阅催缴锚点用
invoice.payment_failed;恢复访问用invoice.paid。 - 同时订阅
invoice.payment_failed+invoice.updated拿next_payment_attempt。 - 每次失败读
attempt_count,按它决定通知节奏,别自己猜重试时间(与 Smart Retries 冲突)。 - 用
decline_code分硬/软拒绝;硬拒绝别硬等重试,直接引导换卡。 - 门控:sub status 为
past_due/unpaid时降权;invoice.paid后恢复。注意past_due与unpaid的语义差异(unpaid之后 invoice 只进 draft)。 - 处理
invoice.finalization_failed与invoice.payment_action_required两个易漏事件。 - 验签用原始 body(见站内 #7416 Stripe Webhook 验签失败),幂等按
event.id。
官方文档链接
- https://docs.stripe.com/billing/revenue-recovery/smart-retries
- https://docs.stripe.com/billing/subscriptions/webhooks
- https://docs.stripe.com/billing/subscriptions/overview
- https://docs.stripe.com/webhooks
- https://docs.stripe.com/api/events/types
注:本文未在真实生产环境逐项实测,字段与限制以官方文档口径为准;Stripe 侧配置项(automations/Retry 设置)随账号与 API 版本可能不同,动手前请以自己 Dashboard 的文案为准。