TL;DR

Stripe fires 3 webhook events during a trial lifecycle. Most developers handle at most 2. The missing one causes silent failures.

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

"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

  1. Receive the event and extract the subscription object
  2. Get the customer ID from the subscription
  3. Look up the user in your database by Stripe customer ID
  4. Check whether a default payment method exists on the subscription or customer
  5. Send warning email with trial end date and a link to update billing
  6. 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."

This post is part of a series on Stripe webhook patterns. The Stripe webhooks for autonomous fulfillment post covers the 3-component listener architecture (FastAPI listener, fulfillment router, delivery agent) that underpins the handler structure here. Start there if you haven't set up your webhook endpoint yet.

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:

  1. Trial converts to paid: status changes from trialing to active
  2. Trial cancels: status changes from trialing to canceled
  3. Trial ends with failed payment: status changes from trialing to past_due or incomplete

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

  1. Check previous_attributes for status: "trialing" to confirm this is a trial transition
  2. Read the current status field on the subscription object
  3. Branch on status: active = conversion, canceled = cancellation, past_due/incomplete = payment failure
  4. For active: grant paid access and send welcome email
  5. For canceled: revoke access and send cancellation confirmation
  6. For past_due/incomplete: wait for invoice.payment_failed to 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:

  1. 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.deleted before fully canceling.
  2. 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_succeeded fires.

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

  1. Get the subscription ID from the failed invoice
  2. Confirm the subscription status is not active (guard against non-trial payment failures)
  3. Look up the user by Stripe customer ID
  4. Downgrade access in your database to free-tier or read-only
  5. Send a payment prompt email with a link to the billing portal
  6. 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.

The agentic AI Stripe billing webhooks post covers the pattern for wiring multiple event handlers into a single idempotent dispatcher, so the same Stripe event never triggers duplicate database writes or duplicate emails even when Stripe delivers it more than once.

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.

  1. Not registering customer.subscription.trial_will_end in the Stripe dashboard. Go to Developers > Webhooks > your endpoint > Edit > Select events. Add customer.subscription.trial_will_end explicitly. Confirm it appears in the endpoint's registered events list. If it's not there, the event fires but nothing receives it.
  2. Handling subscription.updated without checking previous_attributes.status. This event fires for every subscription change, not just trial ends. Without the previous_attributes check, you'll fire trial-end logic on plan upgrades, coupon redemptions, and quantity changes.
  3. Treating subscription.updated with status: past_due as a conversion. Without branching on status, every trial end looks the same. A user who failed to pay gets the same grant_paid_access() call as a user who converted successfully.
  4. Canceling the subscription in the invoice.payment_failed handler. 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

  1. Install the Stripe CLI: brew install stripe/stripe-cli/stripe (macOS) or the equivalent for your OS
  2. Authenticate: stripe login
  3. Forward webhook events to your local server: stripe listen --forward-to localhost:8000/webhooks/stripe
  4. Trigger a test trial_will_end event: stripe trigger customer.subscription.trial_will_end
  5. Trigger a test invoice.payment_failed event: stripe trigger invoice.payment_failed
  6. Inspect the forwarded payloads in your server logs; confirm the status field and previous_attributes match 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.

If your SaaS is wired correctly on Stripe but still not surfacing in AI assistant answers when buyers search for tools like yours, that's a separate visibility gap. The $197 LLMRadar Audit tells you exactly where your brand shows up in ChatGPT, Claude, and Perplexity, and what to fix. Get the LLMRadar Audit →

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.