Free Trials and Upgrade Flow
The essential playbook for implementing free trials and upgrade flow in your SaaS.
Implementing free trials correctly is mostly an access-control problem, not a checkout UI problem.
The stable setup for a small SaaS is:
- create trials in Stripe
- store subscription state locally
- gate premium features from local entitlements
- update local state from verified webhooks
- downgrade or restrict access automatically when trial status changes
Do not rely on frontend redirects alone. A user returning from Stripe Checkout does not guarantee your app has durable billing state.
Quick Fix / Quick Setup
Use this as the minimum viable trial + upgrade implementation.
# Stripe trial checkout example (Python)
import os
import stripe
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
session = stripe.checkout.Session.create(
mode="subscription",
customer="cus_123", # create/reuse your Stripe customer
line_items=[{"price": "price_monthly_123", "quantity": 1}],
subscription_data={
"trial_period_days": 14,
"metadata": {"user_id": "123", "plan": "pro"}
},
success_url="https://app.example.com/billing/success?session_id={CHECKOUT_SESSION_ID}",
cancel_url="https://app.example.com/billing"
)
print(session.url)
# Minimum webhook events to handle:
# - checkout.session.completed
# - customer.subscription.created
# - customer.subscription.updated
# - customer.subscription.deleted
# - invoice.paid
# - invoice.payment_failed
# Local access rule:
# allow_premium = subscription_status in ["trialing", "active"]Minimum setup checklist:
- create one Stripe customer per account/workspace
- store
stripe_customer_idlocally - create recurring Stripe prices for each plan
- create trial subscriptions through Checkout or server-side subscription creation
- verify Stripe webhook signatures
- upsert local subscription state idempotently
- map subscription status to entitlements in your app
- downgrade or restrict at trial end automatically
Important:
Do not grant or revoke access based only on the browser returning from Checkout. Use Stripe webhooks to update your database and drive entitlements.
What’s happening
A free trial in Stripe usually means a subscription exists in trialing state before the first successful charge.
Your app still needs its own source of truth for access. In practice that means a local subscriptions table tied to your user, account, or workspace and linked to Stripe customer/subscription IDs.
The upgrade flow is the transition from:
- no plan
- free plan
- limited trial
- trial to paid
- one paid plan to another
Your billing system and your entitlement system should be related but separate:
- Stripe manages billing objects and invoice lifecycle
- your app stores mirrored subscription state locally
- your app maps local subscription state to feature access
Trial expiration is not only a billing event. It is also an entitlement transition. You need explicit logic for what happens when a subscription becomes:
trialingactivepast_dueunpaidcanceled
Process Flow
Step-by-step implementation
1. Define trial policy
Before writing code, define:
- trial length, for example
7,14, or30days - whether a card is required up front
- whether trial users get full premium access or partial access
- what happens at trial end without payment
- whether
past_duegets a grace period - whether upgrading during trial ends the trial immediately
Example policy:
- 14-day trial
- no card required
- full premium access during trial
- downgrade to free plan if no valid paid subscription exists after trial
- 3-day grace period for failed renewal after conversion
2. Model billing locally
Create a local subscriptions table. Your app should read from this table for authorization and feature gating.
Example PostgreSQL schema:
create table if not exists subscriptions (
id bigserial primary key,
account_id bigint not null,
stripe_customer_id text not null,
stripe_subscription_id text unique,
stripe_price_id text,
plan_key text not null,
status text not null,
trial_end timestamptz,
current_period_end timestamptz,
cancel_at_period_end boolean not null default false,
last_event_id text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_subscriptions_account_id
on subscriptions(account_id);
create index if not exists idx_subscriptions_customer_id
on subscriptions(stripe_customer_id);Optional webhook idempotency table:
create table if not exists stripe_events (
event_id text primary key,
event_type text not null,
processed_at timestamptz not null default now()
);If billing is workspace-based, use account_id or workspace_id, not user_id, for subscription ownership.
3. Create Stripe products and prices
Create one recurring price per plan in Stripe.
Examples:
starter_monthlypro_monthlypro_yearly
Store Stripe price IDs in config:
STRIPE_PRICE_STARTER_MONTHLY=price_123
STRIPE_PRICE_PRO_MONTHLY=price_456
STRIPE_PRICE_PRO_YEARLY=price_789Keep a local mapping in code:
PRICE_TO_PLAN = {
os.environ["STRIPE_PRICE_STARTER_MONTHLY"]: "starter",
os.environ["STRIPE_PRICE_PRO_MONTHLY"]: "pro",
os.environ["STRIPE_PRICE_PRO_YEARLY"]: "pro_yearly",
}4. Create or reuse a Stripe customer
Do not create a new Stripe customer on every billing action.
Create a customer once and persist it:
def get_or_create_stripe_customer(account):
if account.stripe_customer_id:
return account.stripe_customer_id
customer = stripe.Customer.create(
email=account.billing_email,
metadata={"account_id": str(account.id)}
)
account.stripe_customer_id = customer.id
account.save()
return customer.id5. Start the trial subscription
You can do this with Stripe Checkout or direct subscription creation. Checkout is usually the fastest path for MVPs.
Example server endpoint using Checkout:
import os
import stripe
from flask import jsonify
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
def create_trial_checkout(account, price_id):
customer_id = get_or_create_stripe_customer(account)
session = stripe.checkout.Session.create(
mode="subscription",
customer=customer_id,
line_items=[{"price": price_id, "quantity": 1}],
subscription_data={
"trial_period_days": 14,
"metadata": {
"account_id": str(account.id),
"plan_key": "pro"
}
},
success_url="https://app.example.com/billing/success?session_id={CHECKOUT_SESSION_ID}",
cancel_url="https://app.example.com/billing",
)
return jsonify({"url": session.url})If you need stricter control, create the subscription directly server-side instead.
6. Save metadata for mapping
Metadata reduces webhook ambiguity.
Add metadata to whichever object you rely on for mapping:
- Checkout Session metadata
- Subscription metadata
- Customer metadata
Recommended: store account_id on both customer and subscription.
subscription_data={
"trial_period_days": 14,
"metadata": {
"account_id": str(account.id),
"plan_key": "pro"
}
}7. Implement the webhook endpoint
You need verified webhooks and idempotent processing.
Example Flask webhook handler:
import os
import stripe
from flask import request, abort
endpoint_secret = os.environ["STRIPE_WEBHOOK_SECRET"]
def stripe_webhook():
payload = request.data
sig_header = request.headers.get("Stripe-Signature")
try:
event = stripe.Webhook.construct_event(payload, sig_header, endpoint_secret)
except ValueError:
abort(400)
except stripe.error.SignatureVerificationError:
abort(400)
if already_processed(event["id"]):
return "", 200
event_type = event["type"]
obj = event["data"]["object"]
if event_type == "checkout.session.completed":
handle_checkout_completed(obj)
elif event_type == "customer.subscription.created":
sync_subscription(obj, event["id"])
elif event_type == "customer.subscription.updated":
sync_subscription(obj, event["id"])
elif event_type == "customer.subscription.deleted":
sync_subscription(obj, event["id"])
elif event_type == "invoice.paid":
handle_invoice_paid(obj, event["id"])
elif event_type == "invoice.payment_failed":
handle_invoice_failed(obj, event["id"])
mark_processed(event["id"], event_type)
return "", 200At minimum, handle:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paidinvoice.payment_failed
For deeper Stripe setup details, see Stripe Subscription Setup (Step-by-Step) and Handling Webhooks Correctly.
8. Sync Stripe subscription state locally
Your local row should be updated from Stripe subscription data.
Example sync function:
from datetime import datetime, timezone
def ts_to_dt(ts):
if ts is None:
return None
return datetime.fromtimestamp(ts, tz=timezone.utc)
def sync_subscription(subscription, event_id):
customer_id = subscription["customer"]
subscription_id = subscription["id"]
status = subscription["status"]
price_id = None
items = subscription.get("items", {}).get("data", [])
if items:
price_id = items[0]["price"]["id"]
metadata = subscription.get("metadata", {})
account_id = metadata.get("account_id") or find_account_id_by_customer(customer_id)
plan_key = metadata.get("plan_key") or map_price_to_plan(price_id)
upsert_subscription(
account_id=account_id,
stripe_customer_id=customer_id,
stripe_subscription_id=subscription_id,
stripe_price_id=price_id,
plan_key=plan_key,
status=status,
trial_end=ts_to_dt(subscription.get("trial_end")),
current_period_end=ts_to_dt(subscription.get("current_period_end")),
cancel_at_period_end=subscription.get("cancel_at_period_end", False),
last_event_id=event_id,
)9. Gate premium features from local entitlements
Do not call Stripe on every request.
Example access rule:
def account_has_premium(subscription):
if not subscription:
return False
return subscription.status in ["trialing", "active"]If you allow grace periods:
def account_has_premium(subscription):
if not subscription:
return False
if subscription.status in ["trialing", "active"]:
return True
if subscription.status == "past_due" and within_grace_period(subscription):
return True
return FalseIf your app has multiple premium features, map subscription state into entitlements instead of checking raw status everywhere.
For status design, see Managing Subscription States.
10. Build the upgrade flow
Typical in-app billing page actions:
- start trial
- upgrade from free to paid
- switch monthly to yearly
- manage billing
- cancel or resume
Recommended split:
- first-time purchase or free-to-paid: create Stripe Checkout Session
- self-serve plan changes/cancel/resume: use Stripe Billing Portal unless you need custom seat logic
Example Billing Portal session:
def create_billing_portal(account):
customer_id = get_or_create_stripe_customer(account)
session = stripe.billing_portal.Session.create(
customer=customer_id,
return_url="https://app.example.com/billing"
)
return {"url": session.url}11. Handle trial end and failed payment states
Trial-end state must be explicit.
Recommended mapping:
| Stripe status | Access |
|---|---|
| trialing | allow premium |
| active | allow premium |
| past_due | optional grace or restrict |
| unpaid | restrict |
| canceled | restrict after period end or immediately per policy |
Example downgrade job:
def enforce_entitlements(account_id):
sub = get_current_subscription(account_id)
if not sub:
set_plan(account_id, "free")
revoke_premium(account_id)
return
if sub.status in ["trialing", "active"]:
set_plan(account_id, sub.plan_key)
grant_premium(account_id)
return
if sub.status == "past_due" and within_grace_period(sub):
set_plan(account_id, sub.plan_key)
grant_premium(account_id)
return
set_plan(account_id, "free")
revoke_premium(account_id)For billing-specific logic:
customer.subscription.updateddetects status transitions and trial end updatesinvoice.payment_failedtriggers warning emails and grace-period logicinvoice.paidconfirms successful conversion or renewal
12. Test the full lifecycle
You need to test all critical paths in Stripe test mode.
Test:
- signup with trial
- successful checkout
- trialing access in app
- trial converts to active
- failed payment after trial
- downgrade to free
- cancel at period end
- resume
- duplicate webhook retries
- repeated checkout clicks
Useful commands:
stripe listen --forward-to localhost:8000/webhooks/stripe
stripe trigger checkout.session.completed
stripe trigger customer.subscription.created
stripe trigger customer.subscription.updated
stripe trigger invoice.paid
stripe trigger invoice.payment_failedInspect local state:
curl -X GET http://localhost:8000/api/billing/subscriptionpsql "$DATABASE_URL" -c "select account_id, plan_key, status, trial_end, current_period_end, stripe_customer_id, stripe_subscription_id from subscriptions order by updated_at desc limit 20;"grep -i "stripe\|webhook\|subscription" /var/log/app.log | tail -n 200Common causes
Most trial and upgrade bugs come from one of these:
- granting premium access from the frontend success redirect instead of webhooks
- not storing
stripe_customer_idandstripe_subscription_idlocally - creating a new Stripe customer on every upgrade attempt
- not handling
customer.subscription.updated, so trial transitions never sync - missing idempotency in webhook processing, causing duplicate updates or duplicated subscriptions
- not mapping subscription status to product entitlements explicitly
- forgetting to downgrade or restrict users when trial ends or payment fails
- using user-level billing for a team product instead of account/workspace-level billing
- not testing failed payment behavior after trial conversion
- incorrect timezone assumptions around
trial_endorcurrent_period_end
Debugging tips
If users return from Checkout but no access is granted:
- confirm webhook delivery in Stripe dashboard
- confirm signature verification succeeds
- confirm the webhook updated your local subscription row
- confirm your app reads local status, not stale session state
If trial status is wrong locally:
- compare the latest Stripe subscription object with your database row
- inspect
trial_end,current_period_end, andstatus - confirm your sync function handles both
createdandupdated
If upgrades create duplicate subscriptions:
- verify the same
stripe_customer_idis reused - check whether an active or trialing subscription already exists before creating a new one
- make the create-checkout endpoint idempotent
If plan changes do not reflect in the app:
- confirm your webhook updates both
plan_keyandstatus - verify your entitlement mapping uses current local data
- confirm Billing Portal changes are handled through subscription webhooks
If users lose access too early:
- inspect UTC conversion for
trial_endandcurrent_period_end - verify your grace-period logic
- confirm you are not revoking access on
cancel_at_period_end=truebefore the period actually ends
If Stripe metadata is missing:
- confirm metadata was attached to the subscription object, not only the frontend request
- fall back to customer-level mapping using stored
stripe_customer_id
Useful commands:
stripe listen --forward-to localhost:8000/webhooks/stripe
stripe trigger checkout.session.completed
stripe trigger customer.subscription.created
stripe trigger customer.subscription.updated
stripe trigger invoice.paid
stripe trigger invoice.payment_failed
curl -X GET http://localhost:8000/api/billing/subscription
psql "$DATABASE_URL" -c "select account_id, plan_key, status, trial_end, current_period_end, stripe_customer_id, stripe_subscription_id from subscriptions order by updated_at desc limit 20;"
grep -i "stripe\|webhook\|subscription" /var/log/app.log | tail -n 200Checklist
- ✓ Trial length and policy are defined
- ✓ Card-upfront vs no-card trial policy is defined
- ✓ Stripe products and prices are created
- ✓
stripe_customer_idis stored locally - ✓
subscriptionstable is implemented - ✓ Checkout session or direct subscription creation endpoint is implemented
- ✓ Webhook signature verification is enabled
- ✓ Webhook events are processed idempotently
- ✓ Premium entitlements are mapped from local subscription state
- ✓ Trial-end downgrade logic is implemented
- ✓ Failed payment and grace-period behavior is implemented
- ✓ Billing page includes upgrade and manage subscription actions
- ✓ Workspace-level billing is used if the product is team-based
- ✓ Test mode scenarios are verified end-to-end
For final deployment review, also use the SaaS Production Checklist.
Related guides
- Stripe Subscription Setup (Step-by-Step)
- Handling Webhooks Correctly
- Managing Subscription States
- SaaS Production Checklist
FAQ
Should I require a card for a free trial?
Use no-card trials for lower signup friction. Use card-upfront trials for stronger conversion to paid and lower abuse risk. Pick based on your product and traffic quality.
Should free trials be tied to users or workspaces?
For most SaaS products, tie billing to the workspace or account. This avoids inconsistent access when multiple users belong to the same paid organization.
Should I create trials in Stripe or in my own database?
Create the billing trial in Stripe. Mirror the subscription locally for access control and app logic.
Can I just use the Stripe success_url to enable premium features?
No. success_url is not reliable enough for entitlement updates. Always process verified webhooks and update your database.
What Stripe webhook events are required for trial flows?
At minimum:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paidinvoice.payment_failed
What status should allow access?
Usually trialing and active. Optionally allow a short grace period for past_due. Restrict unpaid and canceled according to your downgrade policy.
How should I handle users after a trial ends without payment?
Downgrade them to a free plan or restrict premium features immediately based on a defined policy. Do not leave post-trial access undefined.
Can I offer upgrade during an active trial?
Yes. Either convert immediately to paid billing or preserve the remaining trial period. Be explicit in both backend logic and UI behavior.
How do I avoid duplicate subscriptions?
Reuse the same Stripe customer, check for an existing active or trialing subscription before creating a new one, and make the create-checkout endpoint idempotent.
What is the safest source of truth for app access?
Your local database, updated from verified Stripe webhooks. Stripe is the billing source of truth, but your app should not depend on live Stripe calls on every request.
Final takeaway
The stable implementation is simple:
- create the trial in Stripe
- mirror subscription state locally
- gate features from local entitlements
- update everything from verified webhooks
Most free-trial bugs come from trusting redirects instead of subscription events.
Process Flow