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.

python
# 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_id locally
  • 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:

  • trialing
  • active
  • past_due
  • unpaid
  • canceled
signup
checkout
trialing
active/past_due/canceled
entitlement mapping

Process Flow

Step-by-step implementation

1. Define trial policy

Before writing code, define:

  • trial length, for example 7, 14, or 30 days
  • 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_due gets 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:

sql
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:

sql
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_monthly
  • pro_monthly
  • pro_yearly

Store Stripe price IDs in config:

bash
STRIPE_PRICE_STARTER_MONTHLY=price_123
STRIPE_PRICE_PRO_MONTHLY=price_456
STRIPE_PRICE_PRO_YEARLY=price_789

Keep a local mapping in code:

python
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:

python
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.id

5. 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:

python
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.

python
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:

python
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 "", 200

At minimum, handle:

  • checkout.session.completed
  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.paid
  • invoice.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:

python
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:

python
def account_has_premium(subscription):
    if not subscription:
        return False

    return subscription.status in ["trialing", "active"]

If you allow grace periods:

python
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 False

If 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:

python
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 statusAccess
trialingallow premium
activeallow premium
past_dueoptional grace or restrict
unpaidrestrict
canceledrestrict after period end or immediately per policy

Example downgrade job:

python
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.updated detects status transitions and trial end updates
  • invoice.payment_failed triggers warning emails and grace-period logic
  • invoice.paid confirms 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:

bash
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

Inspect local state:

bash
curl -X GET http://localhost:8000/api/billing/subscription
bash
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;"
bash
grep -i "stripe\|webhook\|subscription" /var/log/app.log | tail -n 200

Common 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_id and stripe_subscription_id locally
  • 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_end or current_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, and status
  • confirm your sync function handles both created and updated

If upgrades create duplicate subscriptions:

  • verify the same stripe_customer_id is 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_key and status
  • 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_end and current_period_end
  • verify your grace-period logic
  • confirm you are not revoking access on cancel_at_period_end=true before 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:

bash
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 200

Checklist

  • Trial length and policy are defined
  • Card-upfront vs no-card trial policy is defined
  • Stripe products and prices are created
  • stripe_customer_id is stored locally
  • subscriptions table 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

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.completed
  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.paid
  • invoice.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.

signup
trialing
active/past_due/canceled
entitlement mapping

Process Flow