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.
Subscription States
Quick Fix / Quick Setup
Use one normalized subscription state in your database and update it only from verified webhook events.
# 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
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:
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_idoruser_idstripe_customer_idstripe_subscription_idstripe_price_idstatusplan_keycurrent_period_startcurrent_period_endtrial_endcancel_at_period_endlast_event_idupdated_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:
activetrialingpast_dueincompleteunpaidcanceled
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.
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.
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 FalseRecommended default policy:
active: allowtrialing: allowpast_due: allow grace or limited accessincomplete: blockunpaid: blockcanceled: 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:
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.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paidinvoice.payment_failed
FastAPI example:
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.idalready exists - if yes, skip
- otherwise process and store
event.idin the same transaction
Example:
CREATE TABLE stripe_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);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.
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:
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 = activecancel_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:
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 case | Primary Stripe event(s) to simulate | Expected internal state | Expected access policy |
|---|---|---|---|
| New trial starts | customer.subscription.created with status=trialing | trialing | Allow paid features |
| Paid checkout completes successfully | checkout.session.completed then invoice.paid / customer.subscription.updated with status=active | active | Allow paid features |
| Initial payment fails | customer.subscription.created or customer.subscription.updated with status=incomplete, optionally followed by invoice.payment_failed | incomplete | Block paid features until billing succeeds |
| Retry succeeds after failure | invoice.paid then customer.subscription.updated with status=active | active | Restore paid feature access |
| Plan upgrade | customer.subscription.updated with a new price and status=active | active | Keep access allowed while plan metadata updates |
| Plan downgrade | customer.subscription.updated with a lower tier price and status=active | active | Keep access allowed while entitlements shift to the lower plan |
| Immediate cancellation | customer.subscription.deleted or customer.subscription.updated with status=canceled and no remaining service period | canceled | Block paid features immediately |
| End-of-period cancellation | customer.subscription.updated with cancel_at_period_end=true, then customer.subscription.deleted at expiry | active until period end, then canceled | Allow access until current_period_end, then block |
| Duplicate webhook delivery | Same event.id delivered more than once for any of the above events | No state change after first successful processing | Access policy remains unchanged |
| Missed webhook followed by reconciliation | Scheduled reconciliation fetches the latest subscription object from Stripe | Match the live Stripe-derived internal state | Access 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, orcancel_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
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.paidIf you have a saved event payload:
curl -X POST http://localhost:8000/webhooks/stripe \
-H 'Content-Type: application/json' \
-d @event.jsonCheck local subscription state
Postgres:
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:
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
grep -i "stripe\|webhook\|subscription\|invoice" /var/log/app.log | tail -n 100
journalctl -u your-app.service -n 100 --no-pagerWhat 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.paidis 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_endis not being treated ascanceled
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_endandcurrent_period_endare 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
- SaaS Production Checklist
- SaaS Architecture Overview (From MVP to Production)
- Structuring a Flask/FastAPI SaaS Project
- Choosing a Tech Stack for a Small SaaS
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:
- normalize Stripe statuses into a small internal state model
- process verified webhooks idempotently
- store subscription state locally
- enforce access from that local record
- 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.