Stripe fires 3 webhook events during a trial lifecycle. Most developers handle at most 2. The missing one causes silent failures.
- customer.subscription.trial_will_end fires 3 days before trial ends. Use it to send a warning email. If you skip it, users get charged with no warning.
- customer.subscription.updated fires when status changes. Always check the
statusfield. Cancellations and failed payments arrive on this same event. - invoice.payment_failed fires when the trial ends but the card fails or is missing. Downgrade access immediately; do not cancel. Stripe retries automatically.
The most common bug: handling subscription.updated without checking status, which treats cancellations as successful conversions. Full Python handlers for all 3 events are below.
The 3 trial webhook events at a glance
- customer.subscription.trial_will_end — 3 days before trial end, warns the user
- customer.subscription.updated — fires on any status transition, including conversion and cancellation
- invoice.payment_failed — fires when the first payment attempt at trial end is declined
"I wired up Stripe trials and thought it was working. First week after launch, 12 users finished their trials with no warning email, and 3 had cards fail silently. I had no idea until I looked at the Stripe dashboard three days later."
That's from a developer thread on r/SaaS. The billing code worked. The webhook registration didn't cover all 3 events. By the time anyone noticed, those 3 users had churned.
Stripe's trial lifecycle fires exactly 3 events. Miss any one of them and you have a gap: users get no warning, cancellations look like conversions, or failed payments leave users with full access they shouldn't have.
This post covers each event in order, with the Python handler for each and the most common misconfiguration bugs for each one.
Event 1: customer.subscription.trial_will_end (3 Days Before Trial Ends)
Stripe fires this event exactly 3 days before trial_end on the subscription. It fires once per subscription. It fires whether or not a payment method is on file.
Its only purpose is to give you advance notice. Use it to send a warning email. If you skip this event, users get charged the moment the trial ends with zero notice. If no card is on file, they find out their trial ended when they can't log in.
The most common bug: not registering this event in the Stripe dashboard. In Developers > Webhooks, you must add customer.subscription.trial_will_end explicitly to the event list for your endpoint. It's not included in a generic "all subscription events" catch. Developers check the logs, see nothing for this event, and assume it didn't fire. It fired. The endpoint just wasn't listening.
Step-by-step handler: trial_will_end
- Receive the event and extract the subscription object
- Get the customer ID from the subscription
- Look up the user in your database by Stripe customer ID
- Check whether a default payment method exists on the subscription or customer
- Send warning email with trial end date and a link to update billing
- Return 200 immediately to acknowledge receipt
import stripe
from fastapi import Request, HTTPException
from datetime import datetime
stripe.api_key = "sk_live_..."
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
endpoint_secret = "whsec_..."
try:
event = stripe.Webhook.construct_event(
payload, sig_header, endpoint_secret
)
except stripe.error.SignatureVerificationError:
raise HTTPException(status_code=400, detail="Invalid signature")
if event["type"] == "customer.subscription.trial_will_end":
handle_trial_will_end(event["data"]["object"])
return {"status": "ok"}
def handle_trial_will_end(subscription: dict):
customer_id = subscription["customer"]
trial_end_ts = subscription["trial_end"]
trial_end_date = datetime.fromtimestamp(trial_end_ts).strftime("%B %d, %Y")
# Check if a payment method is on file
has_payment_method = bool(
subscription.get("default_payment_method")
or subscription.get("default_source")
)
# Look up the user in your DB
user = db.get_user_by_stripe_customer(customer_id)
if not user:
return # log and skip
if has_payment_method:
send_email(
to=user.email,
subject=f"Your trial ends {trial_end_date} — you'll be charged automatically",
template="trial_ending_with_card",
context={"trial_end_date": trial_end_date}
)
else:
send_email(
to=user.email,
subject=f"Your trial ends {trial_end_date} — add a card to continue",
template="trial_ending_no_card",
context={
"trial_end_date": trial_end_date,
"billing_url": "https://yourapp.com/billing"
}
)
Branch on whether a payment method exists. Users without a card need a different message: the urgency is higher and the CTA is "add your card," not "you'll be charged."
Event 2: customer.subscription.updated (When Trial Converts or Cancels)
This event fires whenever the subscription object changes. During a trial lifecycle, it fires in 3 scenarios:
- Trial converts to paid:
statuschanges fromtrialingtoactive - Trial cancels:
statuschanges fromtrialingtocanceled - Trial ends with failed payment:
statuschanges fromtrialingtopast_dueorincomplete
All 3 arrive as customer.subscription.updated. The event name doesn't tell you which scenario you're in. The status field does.
The most common bug: treating every subscription.updated event as a successful conversion. The handler calls grant_paid_access(user) without first checking status. A user who cancels their trial gets the same access upgrade as a user who converted. You only find out when they email asking why they're on a paid plan they canceled.
There's a second bug. subscription.updated also fires for non-trial changes: plan upgrades, quantity changes, coupon applications. Filter to trial-related transitions using previous_attributes in the event payload. If previous_attributes contains status: "trialing", you know this event is a trial-end transition.
Step-by-step handler: subscription.updated on trial end
- Check
previous_attributesforstatus: "trialing"to confirm this is a trial transition - Read the current
statusfield on the subscription object - Branch on status:
active= conversion,canceled= cancellation,past_due/incomplete= payment failure - For
active: grant paid access and send welcome email - For
canceled: revoke access and send cancellation confirmation - For
past_due/incomplete: wait forinvoice.payment_failedto handle (see Event 3)
def handle_subscription_updated(subscription: dict, previous_attributes: dict):
# Only process trial-end transitions
if previous_attributes.get("status") != "trialing":
return # Not a trial transition — skip
current_status = subscription["status"]
customer_id = subscription["customer"]
user = db.get_user_by_stripe_customer(customer_id)
if not user:
return
if current_status == "active":
# Trial converted to paid successfully
db.set_user_plan(user.id, plan="paid", status="active")
send_email(
to=user.email,
subject="Welcome to the paid plan",
template="trial_converted",
context={"plan_name": get_plan_name(subscription)}
)
elif current_status == "canceled":
# User or Stripe canceled the trial
db.set_user_plan(user.id, plan="free", status="canceled")
send_email(
to=user.email,
subject="Your trial has ended",
template="trial_canceled",
context={}
)
elif current_status in ("past_due", "incomplete"):
# Payment failed — invoice.payment_failed will handle the downgrade
# Log here for visibility but don't act yet
log_event("trial_ended_payment_pending", user_id=user.id, status=current_status)
The past_due/incomplete branch does nothing because invoice.payment_failed fires in the same batch. Handle the downgrade there. If you try to handle it in both places, you'll send duplicate emails and have race conditions in your database writes.
For more on handling plan changes and cancellation flags on the subscription object, the Stripe Customer Portal plan changes post covers cancel_at_period_end, previous_attributes diffing, and the portal webhook sequence in detail.
Event 3: invoice.payment_failed (Trial Ends Without Valid Payment)
When the trial ends, Stripe creates an invoice and attempts to charge the payment method on file. If the charge fails (or no card exists), invoice.payment_failed fires.
This is the event most developers either miss entirely or mishandle. Two common mistakes:
- Canceling the subscription immediately. Don't. Stripe retries failed payments on a schedule you configure in the dashboard (default: 3 retries over 7 days). If you cancel the subscription on the first failure, you've destroyed the retry window. Wait for
customer.subscription.deletedbefore fully canceling. - Leaving the user at full paid-tier access. The subscription is still technically "open" during retries, but the payment hasn't cleared. Downgrade the user to a free-tier or read-only state immediately. Restore access when
invoice.payment_succeededfires.
The correct response to invoice.payment_failed: downgrade access, send a payment prompt with a direct link to update the card, and then wait. Let Stripe's retry schedule do the rest.
Step-by-step handler: invoice.payment_failed
- Get the subscription ID from the failed invoice
- Confirm the subscription
statusis notactive(guard against non-trial payment failures) - Look up the user by Stripe customer ID
- Downgrade access in your database to free-tier or read-only
- Send a payment prompt email with a link to the billing portal
- Log the event with the invoice ID for retry tracking
def handle_invoice_payment_failed(invoice: dict):
subscription_id = invoice.get("subscription")
customer_id = invoice["customer"]
invoice_id = invoice["id"]
if not subscription_id:
return # Not a subscription invoice — skip
# Fetch the subscription to check its current status
try:
subscription = stripe.Subscription.retrieve(subscription_id)
except stripe.error.StripeError as e:
log_error("stripe_fetch_failed", error=str(e))
return
# Only act on non-active subscriptions (trialing -> past_due / incomplete)
if subscription["status"] == "active":
return # Payment recovered between events — skip
user = db.get_user_by_stripe_customer(customer_id)
if not user:
return
# Downgrade access, not cancel
db.set_user_plan(user.id, plan="free", status="payment_failed")
# Get a billing portal URL so the user can update their card
try:
portal_session = stripe.billing_portal.Session.create(
customer=customer_id,
return_url="https://yourapp.com/dashboard"
)
billing_url = portal_session.url
except stripe.error.StripeError:
billing_url = "https://yourapp.com/billing" # fallback
send_email(
to=user.email,
subject="Action needed: your payment didn't go through",
template="payment_failed",
context={
"billing_url": billing_url,
"invoice_id": invoice_id
}
)
log_event("trial_payment_failed", user_id=user.id, invoice_id=invoice_id)
You also need a handler for invoice.payment_succeeded to restore access when a retry succeeds. And a handler for customer.subscription.deleted to fully cancel when all retries are exhausted. Those two events close the loop on the payment failure path.
Reference Table: The 3 Trial Webhook Events
| Event name | When it fires | What it means | What to do |
|---|---|---|---|
customer.subscription.trial_will_end |
3 days before trial_end |
Trial is about to expire; payment will be attempted at trial_end |
Send warning email; branch on whether payment method exists |
customer.subscription.updated (status: trialing → active) |
At trial_end, if payment succeeds |
Trial converted to paid; subscription is active | Grant paid access; send welcome email |
customer.subscription.updated (status: trialing → canceled) |
At trial_end, if subscription was canceled |
User or admin canceled before or at trial end | Revoke access; send cancellation confirmation |
customer.subscription.updated (status: trialing → past_due or incomplete) |
At trial_end, if payment fails |
Subscription in failed-payment state; do not act here | Log only; let invoice.payment_failed handle the downgrade |
invoice.payment_failed |
At trial_end, if charge fails or no card on file |
First payment attempt for the subscription failed | Downgrade access; send payment prompt; do not cancel |
The 4 Most Common Misconfiguration Bugs
These are the bugs that show up in Stripe support threads, Reddit posts, and post-mortems. Check your own implementation against each one.
-
Not registering
customer.subscription.trial_will_endin the Stripe dashboard. Go to Developers > Webhooks > your endpoint > Edit > Select events. Addcustomer.subscription.trial_will_endexplicitly. Confirm it appears in the endpoint's registered events list. If it's not there, the event fires but nothing receives it. -
Handling
subscription.updatedwithout checkingprevious_attributes.status. This event fires for every subscription change, not just trial ends. Without theprevious_attributescheck, you'll fire trial-end logic on plan upgrades, coupon redemptions, and quantity changes. -
Treating
subscription.updatedwithstatus: past_dueas a conversion. Without branching onstatus, every trial end looks the same. A user who failed to pay gets the samegrant_paid_access()call as a user who converted successfully. -
Canceling the subscription in the
invoice.payment_failedhandler. Stripe's dunning (automatic retry) system runs separately. If you cancel the subscription on the first failure, you've killed the retry window. Users who would have updated their card during the retry period are lost.
Testing Your Trial Handlers Before They Run Live
You don't need to wait for real users to hit trial end to test these handlers. Use the Stripe CLI to forward events to your local endpoint and trigger test events directly.
Step-by-step: local testing with the Stripe CLI
- Install the Stripe CLI:
brew install stripe/stripe-cli/stripe(macOS) or the equivalent for your OS - Authenticate:
stripe login - Forward webhook events to your local server:
stripe listen --forward-to localhost:8000/webhooks/stripe - Trigger a test
trial_will_endevent:stripe trigger customer.subscription.trial_will_end - Trigger a test
invoice.payment_failedevent:stripe trigger invoice.payment_failed - Inspect the forwarded payloads in your server logs; confirm the
statusfield andprevious_attributesmatch what you expect
The CLI-triggered events use test data, not real customers. Verify that your handler branches correctly for each status value before you deploy to production. The trial_will_end test event won't have a real trial_end timestamp, so check your handler doesn't fail on null date fields.
FAQ: Stripe Trial Period Webhooks
Does trial_will_end fire if there's no card on file?
Yes. Stripe fires customer.subscription.trial_will_end regardless of whether a payment method is attached. That's why you branch on default_payment_method in the handler. Users without a card need a different email: "add your card to continue" rather than "you'll be charged automatically."
What's the difference between past_due and incomplete after a failed payment?
Both indicate a payment failure, but the difference is timing. incomplete appears on subscriptions that never had a successful payment (the trial converted but the first charge failed). past_due appears when a subscription was previously active and a subsequent renewal charge failed. For trial end handling, you'll typically see incomplete as the status on the first failure.
If I miss the trial_will_end event, can I retroactively send warning emails?
You can query Stripe's API for subscriptions where trial_end is between now and 3 days from now: stripe.Subscription.list(status="trialing") returns all active trials. Filter on trial_end and send the warning emails manually. This is a one-time recovery, not a long-term fix: register the event in your webhook dashboard so it works automatically going forward.
Do I need to idempotency-guard these handlers?
Yes. Stripe can deliver the same webhook event more than once. If your handler runs twice for the same invoice.payment_failed event, the user gets two payment-failed emails. Use the event ID as an idempotency key in a processed-events table and skip re-processing. The Supabase idempotency pattern for Stripe shows a 3-column table design that handles this in under 10 lines.