How to Handle Stripe Subscriptions from Trial to Dunning
One-off payments are a solved problem. Subscriptions are where billing code gets interesting, because the state machine has more states than most teams plan for: trialing, active, past_due, unpaid, incomplete, canceled.
Direct answer: handle Stripe subscriptions by listening to four webhook events — invoice.paid, invoice.payment_failed, customer.subscription.updated, and customer.subscription.deleted — and reading subscription status from Stripe rather than storing your own copy. Access should follow the status field, not your database’s guess.
How do trials actually work?
Two kinds, and they behave differently. trial_period_days on the subscription creates a trial with no payment method required. payment_method_collection="always" forces a card up front.
stripe.checkout.Session.create(
mode="subscription",
line_items=[{"price": PRICE_ID, "quantity": 1}],
subscription_data={"trial_period_days": 14},
payment_method_collection="always",
)
Requiring a card cuts trial starts by roughly a third but converts 2-3x better at the end. If your product is low-friction, let people in without a card. If trial abuse would cost you money, require one.
During the trial the status is trialing — distinct from active. Treat them the same for access, but not for analytics or for emails.
What happens when a trial ends?
If no card was collected, Stripe tries to create an invoice and the subscription flips to active with a past_due invoice. No card means the invoice cannot be charged, so you get incomplete then canceled after about 23 hours.
You get customer.subscription.trial_will_end three days before. That is the event to trigger the “your trial ends soon” email — not a cron job of your own.
How should proration be handled?
By default Stripe prorates plan changes immediately and charges the difference now. For an upgrade that is what users expect. For a downgrade it means a credit on the next invoice, which confuses people who just watched their monthly charge appear.
stripe.Subscription.modify(sub_id,
items=[{"id": item_id, "price": new_price}],
proration_behavior="create_prorations")
The alternatives are "none" (change at period end, no proration) and "always_invoice" (bill the proration immediately rather than bundling it). Downgrades are usually better with "none" plus billing_cycle_anchor="unchanged".
Can Stripe handle dunning for you?
Partly. Stripe retries a failed payment up to four times over three weeks by default, on a schedule you can customise in the Dashboard under Billing → Revenue recovery. The first retry lands the next day.
Your job is the customer-facing side:
- On
invoice.payment_failed, send a short email with a link tohosted_invoice_url. Do not re-implement the card update form. - After the second failure, downgrade access to read-only rather than cutting it — you keep the account, they keep the habit.
- When the subscription hits
unpaid, cancel it. Leaving it inunpaidaccrues nothing and confuses your MRR report.
Revenue recovery from a proper dunning flow recovers 30-40% of involuntary churn. That is the highest-ROI email you will ever send.
What limits should you know about?
| Scenario | Stripe behaviour |
|---|---|
| Trial without card | cancels ~23h after trial if unpaid |
| Card declines at renewal | 4 retries, then unpaid |
| Subscription paused | pause_collection stops invoicing, keeps the customer |
| Cancel at period end | status stays active until period end, then canceled |
| Refund | does not cancel the subscription automatically |
That last row catches people constantly. Issuing a refund leaves the recurring charge running. Cancel the subscription explicitly.
How do you read state without getting it wrong?
Webhooks arrive out of order. customer.subscription.deleted can land before customer.subscription.updated, and if you blindly overwrite your status column you end up with a live-looking subscription that was cancelled.
Two fixes. Compare the created timestamp on each event object and ignore anything older than what you already stored. Or store nothing and call stripe.Subscription.retrieve(id) on every check — 100 ms of latency and zero drift. For most apps the second option is right.
Also always verify the signature. A subscription-status endpoint without signature verification is a free-subscription endpoint.
Who this setup is wrong for
If you sell to consumers in the EU and do not want to register for VAT, a merchant of record handles it and Stripe does not. If your price changes per byte of usage, metered billing works but the reporting gets genuinely complex — test on a small cohort first.
And if your average subscription is $3/month, Stripe’s 2.9% + $0.30 eats 13% per transaction. Bundle annual plans or reconsider the price.
Key Takeaways
- Four webhook events cover the lifecycle:
invoice.paid,invoice.payment_failed,customer.subscription.updated,customer.subscription.deleted. - Requiring a card for trials cuts signups about a third and raises end-of-trial conversion 2-3x.
- Default dunning is four retries over three weeks; pair it with emails for a 30-40% recovery rate.
- Refunds do not cancel subscriptions — that is a separate call.
- Webhooks can arrive out of order; compare event timestamps or read state live from Stripe.
