# Stripe invoice.payment_failed: dunning, Smart Retries, and past_due vs canceled

## The symptom

Webhook `invoice.payment_failed` arrives. The subscription shows `past_due`. Your code has to decide: email the customer, retry, downgrade, or wait.

## Confirm the cause

Read the invoice's payment intent: `invoice.payment_intent.last_payment_error.decline_code`. Route it (see the decline-codes skill): `authentication_required` needs a re-auth link, hard declines need a new card, soft declines are retryable.

## What Stripe does on its own

With Smart Retries enabled (Dashboard > Settings > Subscriptions and emails), Stripe's model picks retry timing per invoice and attempts up to 4 retries over roughly 3 weeks. That beats any fixed schedule most teams hand-roll. Turn it on; there is rarely a reason to leave it off.

The subscription lifecycle on failure:
- First failure: subscription goes `past_due`. The customer keeps access. This is normal and expected, not an emergency.
- Retries continue per your settings.
- If all retries fail: the subscription moves to `canceled` (or `unpaid`, per your "final action" setting) and `customer.subscription.deleted` fires. Do the teardown there, never beside the API call.

## The fix

1. On `invoice.payment_failed`: mark the subscription `past_due` locally and notify. Do not cancel. Do not revoke access yet.
2. Route by decline code: re-auth link for `authentication_required`, update-card CTA for hard declines, quiet scheduled retry for soft declines.
3. Let Smart Retries run. Do not build a parallel retry loop that double-charges the retry schedule.
4. On `customer.subscription.deleted`: revoke access. On `invoice.payment_succeeded`: clear `past_due`.
5. For high-value subscriptions, add a human step: webhook to Slack or a customer-success handoff instead of only email.

## Verify the fix

In test mode with a test clock: attach a failing card, advance past renewal, confirm `invoice.payment_failed` fires and status becomes `past_due`. Advance past the retry window and confirm `customer.subscription.deleted` fires and access is revoked. Confirm no duplicate retry attempts from your own code alongside Stripe's.
