← Back to blog

Your free trial ended three weeks ago. This user still has full access.

September 8, 2026

Trials are supposed to be the safest part of a billing integration — no money moving, low stakes, plenty of time to get it right. They’re also where a specific, quiet assumption tends to break: that a subscription record existing in your database is the same thing as a subscription being active. It isn’t, and trials are exactly where the gap between those two things opens up widest, because a trial can end in three different ways that all look identical from your app’s side if you’re only checking “does this user have a subscription row.”

The three ways a trial ends, and why your app probably only handles one

1. Trial with a card on file, and it converts cleanly

This is the case every integration handles correctly, because it’s the one that gets tested: trial ends, Stripe charges the card, invoice.payment_succeeded fires, subscription status moves to active. Nothing to fix here — this is the happy path everyone builds and verifies first.

2. Trial with a card on file, and the charge fails

The trial ends, Stripe attempts the charge, the card declines. The subscription doesn’t go straight to canceled — it typically moves to past_due or incomplete and enters a retry schedule, exactly like a normal failed renewal. If your access check only asks “is there a subscription for this user” rather than “what is this subscription’s current status,” the user keeps full access through the entire retry window — often a week or more — on a trial that never actually converted to a paying one.

3. Trial with no card required at signup

This is the one that catches teams off guard, because it’s increasingly the default choice for reducing signup friction. With no payment method on file, there’s nothing to charge when the trial ends — Stripe simply moves the subscription to canceled (or leaves it incomplete_expired, depending on your settings) with no invoice, no payment attempt, and critically, no dramatic failure event that’s easy to notice in logs. If your onboarding logic set hasAccess = true once at signup and nothing ever re-checks it, that flag simply never gets revisited. The subscription silently expired weeks ago; your database has no idea.

Two-minute check: Stripe Dashboard → Subscriptions → filter by status canceled, sorted by most recently updated. For the five most recent, check whether that customer’s account in your own app still shows an active plan. This test takes two minutes and catches all three failure modes at once, because it doesn’t care which one caused the mismatch — it just compares Stripe’s current truth against your app’s.

Why “just listen for trial_will_end” isn’t the fix

Stripe does send a customer.subscription.trial_will_end event, typically three days before a trial ends — and it’s genuinely useful for sending a “your trial is ending soon, add a card” email. But it fires before the outcome is known, not after. It tells you a trial is about to end; it says nothing about whether the resulting charge will succeed, fail, or never happen because there was no card to charge in the first place. Treating that event as the signal to revoke or keep access means acting on information that’s three days stale by the time the actual outcome resolves. The event worth listening to is customer.subscription.updated, checking whether the new status is anything other than active or trialing.

Why this specific gap is expensive in a way others aren’t

A failed webhook or a missed cancellation usually affects customers who at some point paid you. A broken trial-end check affects people who never paid you at all — which means the product is being used indefinitely, for free, by users your own funnel already correctly identified as not having converted. For anything with real marginal cost per user — API calls, compute, storage, third-party pass-through costs — this isn’t a rounding error. It’s unconverted trial users quietly becoming a permanent, uncapped cost center, discovered only when someone finally asks why usage costs keep climbing while paid-seat count stays flat.

The fix

What we built this for

I built Venwai after running into this same class of problem from a few different angles — a subscription that never fires the delete event it’s supposed to, a customer who paid and still hit a paywall, and now trials that expire without anything obviously breaking. Venwai connects to Stripe with a read-only key, polls a single endpoint you expose on your own app once a day, and diffs who Stripe currently says should have access against who your app actually grants it — catching exactly this kind of drift regardless of which specific event was the root cause, and alerting you in Slack, Discord, Telegram, or email the moment it finds one.

Free while it’s in beta, no card required: venwai.com.