Managing Subscription States

The essential playbook for implementing managing subscription states in your SaaS.

Intro

This page shows how to implement reliable subscription state handling for a small SaaS using Stripe. The goal is to keep billing status, renewals, cancellations, trials, dunning, and app access in sync without leaking paid access or blocking valid users incorrectly.

Use Stripe as the billing source of truth. Persist a simplified normalized subscription state in your database. Make product access decisions from that local record, not from frontend redirects or live Stripe API calls on every request.

Signup
Trialing
Active
Past Due
Canceled

Subscription States

Quick Fix / Quick Setup

Use one normalized subscription state in your database and update it only from verified webhook events.

python
# Recommended internal subscription state mapping
# Persist one normalized state in your DB and update it only from verified webhooks.

STATE_ACTIVE = "active"
STATE_TRIALING = "trialing"
STATE_PAST_DUE = "past_due"
STATE_CANCELED = "canceled"
STATE_INCOMPLETE = "incomplete"
STATE_UNPAID = "unpaid"

STRIPE_TO_INTERNAL = {
    "trialing": STATE_TRIALING,
    "active": STATE_ACTIVE,
    "past_due": STATE_PAST_DUE,
    "canceled": STATE_CANCELED,
    "incomplete": STATE_INCOMPLETE,
    "incomplete_expired": STATE_CANCELED,
    "unpaid": STATE_UNPAID,
    "paused": STATE_PAST_DUE,
}

# Access policy example
ACCESS_ALLOWED = {STATE_ACTIVE, STATE_TRIALING}
ACCESS_GRACE = {STATE_PAST_DUE}
ACCESS_BLOCKED = {STATE_CANCELED, STATE_INCOMPLETE, STATE_UNPAID}

# Minimal webhook handler rules
# - verify signature
# - dedupe by event.id
# - fetch subscription/customer ids
# - map Stripe status -> internal status
# - update local DB in one transaction
# - never trust redirect success pages alone

# Example pseudo-update
internal_status = STRIPE_TO_INTERNAL.get(stripe_subscription.status, STATE_CANCELED)
update_subscription_record(
    customer_id=stripe_customer_id,
    subscription_id=stripe_subscription.id,
    status=internal_status,
    current_period_end=stripe_subscription.current_period_end,
    cancel_at_period_end=stripe_subscription.cancel_at_period_end,
    price_id=stripe_subscription['items']['data'][0]['price']['id'],
)

# Gate product access from local DB state
if subscription.status in ACCESS_ALLOWED:
    allow_access()
elif subscription.status in ACCESS_GRACE:
    allow_limited_access_or_show_billing_banner()
else:
    block_paid_features()

Best practice: Stripe is the billing source of truth, but your app should make access decisions from a normalized local subscription table updated by verified webhook events.

What’s happening

Stripe subscriptions move through billing states such as trialing, active, past_due, canceled, incomplete, and unpaid.

Your app usually does not need every provider-specific nuance in the authorization layer. It needs a stable internal state model that answers:

  • should this user or workspace have access right now?
  • is the subscription scheduled to end?
  • when does access expire?
  • should billing recovery UI be shown?

Problems happen when app access is tied to:

  • checkout success redirects
  • stale session data
  • one-off frontend calls
  • inconsistent logic across routes, jobs, and admin tools

Reliable subscription state handling depends on:

  • verified Stripe webhooks
  • idempotent event processing
  • a normalized subscription table in your database
  • a clear access policy for each internal state
  • reconciliation jobs for missed events
checkout
Stripe webhook
DB update
middleware access check

sequence diagram for checkout -> Stripe webhook -> DB update -> middleware access check.

Step-by-step implementation

1) Create a local subscriptions table

Store enough data to make access decisions without querying Stripe on every request.

Postgres example:

sql
CREATE TABLE subscriptions (
    id BIGSERIAL PRIMARY KEY,
    account_id BIGINT NOT NULL,
    stripe_customer_id TEXT NOT NULL,
    stripe_subscription_id TEXT UNIQUE NOT NULL,
    stripe_price_id TEXT,
    plan_key TEXT,
    status TEXT NOT NULL,
    current_period_start TIMESTAMPTZ,
    current_period_end TIMESTAMPTZ,
    trial_end TIMESTAMPTZ,
    cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
    canceled_at TIMESTAMPTZ,
    last_event_id TEXT,
    last_invoice_id TEXT,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_subscriptions_account_id ON subscriptions(account_id);
CREATE INDEX idx_subscriptions_customer_id ON subscriptions(stripe_customer_id);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);

Minimum fields:

  • account_id or user_id
  • stripe_customer_id
  • stripe_subscription_id
  • stripe_price_id
  • status
  • plan_key
  • current_period_start
  • current_period_end
  • trial_end
  • cancel_at_period_end
  • last_event_id
  • updated_at

If you support teams or workspaces, bind the subscription to the tenant, not the user.

2) Define a small internal status enum

Keep it stable even if provider details change.

Recommended internal states:

  • active
  • trialing
  • past_due
  • incomplete
  • unpaid
  • canceled

Do not overload cancel_at_period_end into status. Keep it as a separate boolean.

3) Map Stripe states to internal states in one place

Do this in one module and reuse it everywhere.

python
STATE_ACTIVE = "active"
STATE_TRIALING = "trialing"
STATE_PAST_DUE = "past_due"
STATE_CANCELED = "canceled"
STATE_INCOMPLETE = "incomplete"
STATE_UNPAID = "unpaid"

STRIPE_TO_INTERNAL = {
    "trialing": STATE_TRIALING,
    "active": STATE_ACTIVE,
    "past_due": STATE_PAST_DUE,
    "canceled": STATE_CANCELED,
    "incomplete": STATE_INCOMPLETE,
    "incomplete_expired": STATE_CANCELED,
    "unpaid": STATE_UNPAID,
    "paused": STATE_PAST_DUE,
}

4) Define access policy explicitly

Do not let access rules spread across templates, middleware, and jobs.

python
ACCESS_ALLOWED = {"active", "trialing"}
ACCESS_GRACE = {"past_due"}
ACCESS_BLOCKED = {"canceled", "incomplete", "unpaid"}

def can_access_paid_features(subscription) -> bool:
    now = datetime.now(timezone.utc)

    if subscription.status in ACCESS_ALLOWED:
        return True

    if subscription.status == "past_due":
        # policy decision: grace period
        return subscription.current_period_end and subscription.current_period_end > now

    return False

Recommended default policy:

  • active: allow
  • trialing: allow
  • past_due: allow grace or limited access
  • incomplete: block
  • unpaid: block
  • canceled: block, unless cancellation is only scheduled and period has not ended yet

5) Capture Stripe IDs early

When using Stripe Checkout, capture customer and subscription identifiers as early as possible, but do not treat the redirect alone as final entitlement.

Example checkout.session.completed handling:

python
def handle_checkout_completed(session):
    customer_id = session.get("customer")
    subscription_id = session.get("subscription")
    account_id = session["metadata"]["account_id"]

    # Save references if present
    db.execute("""
        UPDATE accounts
        SET stripe_customer_id = %s
        WHERE id = %s
    """, (customer_id, account_id))

    if subscription_id:
        db.execute("""
            INSERT INTO pending_billing_links (account_id, stripe_subscription_id, created_at)
            VALUES (%s, %s, NOW())
            ON CONFLICT DO NOTHING
        """, (account_id, subscription_id))

Use it for linking, not for final access.

6) Handle the core webhook events

At minimum, process:

  • checkout.session.completed
  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.paid
  • invoice.payment_failed

FastAPI example:

python
import os
import stripe
from fastapi import FastAPI, Header, HTTPException, Request

app = FastAPI()
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
endpoint_secret = os.environ["STRIPE_WEBHOOK_SECRET"]

@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request, stripe_signature: str = Header(None, alias="Stripe-Signature")):
    payload = await request.body()

    try:
        event = stripe.Webhook.construct_event(
            payload=payload,
            sig_header=stripe_signature,
            secret=endpoint_secret,
        )
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid payload")
    except stripe.error.SignatureVerificationError:
        raise HTTPException(status_code=400, detail="Invalid signature")

    if already_processed(event["id"]):
        return {"status": "duplicate_ignored"}

    event_type = event["type"]
    data = event["data"]["object"]

    process_event(event_type, data, event["id"])

    return {"status": "ok"}

7) Make webhook processing idempotent

Stripe retries webhooks. Process every event safely more than once.

Basic pattern:

  • verify signature
  • check whether event.id already exists
  • if yes, skip
  • otherwise process and store event.id in the same transaction

Example:

sql
CREATE TABLE stripe_events (
    event_id TEXT PRIMARY KEY,
    event_type TEXT NOT NULL,
    processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
python
def mark_event_processed(tx, event_id, event_type):
    tx.execute("""
        INSERT INTO stripe_events (event_id, event_type)
        VALUES (%s, %s)
    """, (event_id, event_type))

8) Fetch the latest subscription object when needed

Webhook payloads can be enough, but for critical state updates it is often safer to fetch the current subscription object from Stripe before writing to the database.

python
def sync_subscription_from_stripe(subscription_id: str, event_id: str):
    sub = stripe.Subscription.retrieve(
        subscription_id,
        expand=["items.data.price"]
    )

    internal_status = STRIPE_TO_INTERNAL.get(sub.status, "canceled")
    price = sub["items"]["data"][0]["price"] if sub["items"]["data"] else None

    with db.transaction() as tx:
        tx.execute("""
            INSERT INTO subscriptions (
                account_id,
                stripe_customer_id,
                stripe_subscription_id,
                stripe_price_id,
                status,
                current_period_start,
                current_period_end,
                trial_end,
                cancel_at_period_end,
                last_event_id,
                updated_at
            )
            VALUES (%s, %s, %s, %s, %s, to_timestamp(%s), to_timestamp(%s), to_timestamp(%s), %s, %s, NOW())
            ON CONFLICT (stripe_subscription_id)
            DO UPDATE SET
                stripe_customer_id = EXCLUDED.stripe_customer_id,
                stripe_price_id = EXCLUDED.stripe_price_id,
                status = EXCLUDED.status,
                current_period_start = EXCLUDED.current_period_start,
                current_period_end = EXCLUDED.current_period_end,
                trial_end = EXCLUDED.trial_end,
                cancel_at_period_end = EXCLUDED.cancel_at_period_end,
                last_event_id = EXCLUDED.last_event_id,
                updated_at = NOW()
        """, (
            resolve_account_id(sub.customer),
            sub.customer,
            sub.id,
            price["id"] if price else None,
            internal_status,
            sub.current_period_start,
            sub.current_period_end,
            sub.trial_end,
            sub.cancel_at_period_end,
            event_id,
        ))

        mark_event_processed(tx, event_id, "subscription_sync")

9) Keep database updates atomic

Update status, plan, billing period, and event tracking in one transaction. Do not update status in one function and period dates in another.

This prevents partial updates like:

  • status changed but period end not updated
  • plan changed but feature gating still based on old price
  • duplicate webhook recorded before DB state update completed

10) Gate app access from local DB state

Do not call Stripe on every request.

Example middleware check:

python
def require_paid_access(account_id: int):
    subscription = db.fetch_one("""
        SELECT status, cancel_at_period_end, current_period_end
        FROM subscriptions
        WHERE account_id = %s
        ORDER BY updated_at DESC
        LIMIT 1
    """, (account_id,))

    if not subscription:
        raise PermissionError("No subscription")

    if subscription["status"] in {"active", "trialing"}:
        return True

    if subscription["status"] == "past_due":
        return True  # or apply grace policy

    raise PermissionError("Subscription inactive")

11) Handle scheduled cancellation separately

A user can have:

  • status = active
  • cancel_at_period_end = true

That means access should continue until current_period_end.

Do not block access immediately just because the user clicked cancel in the customer portal.

12) Add a reconciliation job

Webhooks can fail. Run a periodic sync job to reconcile local state with Stripe.

Example pseudo-job:

python
def reconcile_subscriptions():
    rows = db.fetch_all("""
        SELECT stripe_subscription_id
        FROM subscriptions
        WHERE status IN ('active', 'trialing', 'past_due', 'incomplete', 'unpaid')
    """)

    for row in rows:
        try:
            sync_subscription_from_stripe(row["stripe_subscription_id"], event_id="reconcile_job")
        except Exception as exc:
            logger.exception("reconcile_failed subscription_id=%s error=%s", row["stripe_subscription_id"], exc)

Run it every few hours or daily depending on billing volume.

13) Add billing diagnostics for admin and support

Expose:

  • local status
  • Stripe subscription ID
  • Stripe customer ID
  • current period end
  • cancel_at_period_end
  • last invoice result
  • last processed event ID
  • last webhook processed timestamp

This reduces time spent debugging support tickets.

14) Test the full lifecycle

Test these cases:

  • new trial starts
  • paid checkout completes
  • initial payment fails
  • retry succeeds
  • plan upgrade
  • plan downgrade
  • immediate cancellation
  • end-of-period cancellation
  • duplicate webhook delivery
  • missed webhook followed by reconciliation

Use this test matrix to verify how billing events should update your internal state and feature access:

Lifecycle casePrimary Stripe event(s) to simulateExpected internal stateExpected access policy
New trial startscustomer.subscription.created with status=trialingtrialingAllow paid features
Paid checkout completes successfullycheckout.session.completed then invoice.paid / customer.subscription.updated with status=activeactiveAllow paid features
Initial payment failscustomer.subscription.created or customer.subscription.updated with status=incomplete, optionally followed by invoice.payment_failedincompleteBlock paid features until billing succeeds
Retry succeeds after failureinvoice.paid then customer.subscription.updated with status=activeactiveRestore paid feature access
Plan upgradecustomer.subscription.updated with a new price and status=activeactiveKeep access allowed while plan metadata updates
Plan downgradecustomer.subscription.updated with a lower tier price and status=activeactiveKeep access allowed while entitlements shift to the lower plan
Immediate cancellationcustomer.subscription.deleted or customer.subscription.updated with status=canceled and no remaining service periodcanceledBlock paid features immediately
End-of-period cancellationcustomer.subscription.updated with cancel_at_period_end=true, then customer.subscription.deleted at expiryactive until period end, then canceledAllow access until current_period_end, then block
Duplicate webhook deliverySame event.id delivered more than once for any of the above eventsNo state change after first successful processingAccess policy remains unchanged
Missed webhook followed by reconciliationScheduled reconciliation fetches the latest subscription object from StripeMatch the live Stripe-derived internal stateAccess follows the reconciled local state

Common causes

These are the usual causes of subscription state bugs:

  • using checkout success redirects as the only activation mechanism
  • not verifying Stripe webhook signatures
  • no idempotency handling for duplicate webhook deliveries
  • missing mapping for statuses like incomplete, unpaid, or cancel_at_period_end
  • treating canceled and scheduled cancellation as the same thing
  • not storing current_period_end, causing premature access removal
  • reading subscription state directly from stale session or cache data
  • supporting multiple plans or tenants without a clear active subscription selection rule
  • not reconciling local state after missed or failed webhook processing
  • updating access in multiple code paths with inconsistent business rules

Debugging tips

Start with Stripe CLI and database inspection.

Stripe CLI

bash
stripe listen --forward-to localhost:8000/webhooks/stripe
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
stripe trigger invoice.paid

If you have a saved event payload:

bash
curl -X POST http://localhost:8000/webhooks/stripe \
  -H 'Content-Type: application/json' \
  -d @event.json

Check local subscription state

Postgres:

bash
psql "$DATABASE_URL" -c "SELECT user_id, stripe_customer_id, stripe_subscription_id, status, cancel_at_period_end, current_period_end, last_event_id, updated_at FROM subscriptions ORDER BY updated_at DESC LIMIT 20;"

SQLite:

bash
sqlite3 app.db "SELECT user_id, stripe_subscription_id, status, cancel_at_period_end, current_period_end, last_event_id, updated_at FROM subscriptions ORDER BY updated_at DESC LIMIT 20;"

If your schema uses account_id instead of user_id, update the query accordingly.

Check logs

bash
grep -i "stripe\|webhook\|subscription\|invoice" /var/log/app.log | tail -n 100
journalctl -u your-app.service -n 100 --no-pager

What to compare

Compare these values between Stripe and your DB:

  • customer ID
  • subscription ID
  • subscription status
  • price ID
  • current period end
  • cancel at period end flag
  • latest invoice status
  • last processed webhook event ID

Specific failure patterns

If access is wrong after successful payment:

  • confirm invoice.paid is processed
  • check whether stale cache is serving old subscription state
  • verify route guards read the same field as webhook updates

If access was removed too early:

  • check current_period_end
  • confirm cancel_at_period_end is not being treated as canceled

If users have multiple subscriptions:

  • confirm your app picks the correct subscription for the product or workspace
  • do not just select the most recent row without filtering by account and product

Checklist

  • Subscriptions table exists with provider IDs, status, period dates, cancel flags, and event tracking
  • Internal status enum is defined and documented
  • Stripe-to-internal state mapping is implemented in one place
  • Webhook signature verification is enabled
  • Webhook processing is idempotent using event IDs
  • Access control reads from local subscription state, not frontend redirects
  • cancel_at_period_end and current_period_end are stored separately from status
  • Failed payment and grace-period policy are implemented
  • A reconciliation job exists for missed webhook recovery
  • Admin or support tools expose billing state for diagnostics
  • Tests cover lifecycle events and duplicate webhook delivery
  • Deployment logging is configured so webhook failures are visible in production
  • Production readiness is reviewed against SaaS Production Checklist

Related guides

FAQ

Which Stripe statuses should grant access?

Usually active and trialing. past_due may get temporary grace access depending on policy. incomplete, unpaid, and canceled typically should not grant paid access.

How do I handle cancellation at period end?

Keep status active until the billing period actually ends, but store cancel_at_period_end=true so the UI can show scheduled cancellation clearly.

What if webhooks arrive more than once?

Store and check event.id before processing. Webhook handlers must be idempotent.

Can I just query Stripe on every request?

You can, but it adds latency and more failure points. A local normalized subscription record is the standard approach.

What should I do if local and Stripe state differ?

Run a reconciliation job, inspect recent webhook delivery logs, and update the local record from the latest Stripe subscription object.

Is checkout.session.completed enough to activate an account?

No. Use it as an early linkage event. Final state should be confirmed from subscription and invoice webhooks.

Should Stripe or my database be the source of truth?

Stripe is the source of truth for billing facts. Your database is the source of truth for app access decisions after syncing verified billing state locally.

Final takeaway

The safest subscription pattern is:

  1. normalize Stripe statuses into a small internal state model
  2. process verified webhooks idempotently
  3. store subscription state locally
  4. enforce access from that local record
  5. reconcile periodically for missed events

Most subscription bugs come from incomplete state models, trusting frontend redirects, or failing to handle webhook retries and scheduled cancellation correctly.