Introduction to SaaS-Oriented Plan, Billing, and Feature Limitation Design in FastAPI: Practical Patterns for Safely Handling Free Plans, Paid Plans, and Subscription States
Summary
- SaaS plan design is not simply about “separating monthly pricing.” Its real essence is making sure the entire application can consistently handle which contract allows which features, and to what extent.
- In FastAPI, beyond authentication, tenant handling, and RBAC/ABAC, the design becomes far less fragile when you organize plan information, subscription state, and usage limits as dependencies and policy functions.
- The important point is not to judge by
planalone. In real-world systems, you must design around subscription states (active,trialing,past_due,canceled, and so on), add-ons, limit values, grace periods, and how failed payments should be handled. - Hiding buttons in the UI is not enough for feature limitation. Final decisions must always be enforced on the backend, and operations become much easier if those decisions can also be recorded in audit logs and event logs.
- This article gradually organizes plan model design in FastAPI, feature flags, usage limits, handling of billing states, webhook-based state transitions, and testing strategies.
Who will benefit from reading this
Individual developers and learners
This is for people who have started thinking, “I want to separate a free plan from a paid plan,” or “CSV export should be available only on paid plans.”
At first, you may want to write something simple like if user.plan == "pro", but once features increase, things quickly become messy. This article provides the foundation for taking a step toward a more organized design.
Backend engineers in small teams
This is for teams building SaaS with FastAPI, where each tenant has a plan and feature restrictions and usage limits have gradually increased.
It helps organize operational questions like “At which layer should we make the decision?”, “What should stop during payment failure?”, and “What should happen after a trial ends?” in a form that is easier to implement.
SaaS development teams and startups
This is for teams facing real issues such as multiple plans, add-ons, usage-based billing, grace periods, failed payments, and reactivation after dormancy.
Rather than focusing on the billing system itself, it centers on how the application can safely reflect subscription state, helping you review design patterns that are less likely to break later.
Accessibility note
- The article first shows the overall picture, then proceeds in the order of “terminology,” “data model,” “feature limits,” “billing state,” “webhooks,” and “testing,” making it easy to follow even if you join midway.
- Technical terms are briefly explained when first introduced, and then the same expressions are used consistently to reduce cognitive load.
- Code examples are split by small responsibilities so that no single block becomes overloaded.
- The target level is approximately AA equivalent.
1. SaaS plan design is not a “pricing table,” but “authorization control”
When you begin building SaaS, the first thing that becomes visible is the pricing page.
It is natural to want to line up a Free plan, a Pro plan, and an Enterprise plan, and from a marketing perspective that part stands out.
However, the essence on the backend side is not how pricing is displayed.
What truly matters is safely determining what that tenant can use right now, to what extent, and under what current subscription state.
For example, even within the same “Pro plan,” the actual situation may differ like this:
- In trial
- Actively paid
- In a grace period after payment failure
- Scheduled for cancellation
- Set to stop at the end of this month
- Only extra capacity purchased via add-ons
Given that reality, a simple plan == "pro" check is not enough.
That is why the center of the design needs information such as:
- Plan type
- Subscription state
- Expiration date
- Usage limits
- Add-ons
- Temporary exceptions or special contracts
This article looks at how to map this whole picture into FastAPI dependencies and policies.
2. Terms to organize first: plan, subscription, subscription state, and feature
To avoid mixing concepts, let us organize the terminology first.
Plan
A plan is a concept close to the “product name” of the feature set being offered.
Examples:
freestarterproenterprise
A plan is relatively static and is the category that also appears on the pricing page.
Subscription
A subscription is the actual contract record representing which plan a tenant is currently on.
It is not the plan itself, but a record that contains the active contractual state.
Subscription state
The subscription state indicates the billing, validity, and stop condition.
Examples:
trialingactivepast_duecanceledpausedexpired
In real work, whether a feature can be used is sometimes influenced more strongly by the subscription state than by the plan name itself.
Feature
A feature is a capability that a user can actually use in the application.
Examples:
- Creating projects
- CSV export
- API access
- Webhook configuration
- Viewing audit logs
- Storage capacity limits
- Member count limits
How to represent these “features” is the core of the application-side design.
3. The design policy you should decide first: avoid hardcoding plan names
A common early implementation looks like this:
if tenant.plan == "pro":
# CSV export allowed
At first, this is very easy to understand.
But after a little time, the following problems appear quickly.
- You may want to enable CSV for
startertoo - Enterprise may need audit logs in addition to CSV
- Certain customers may need CSV as a special exception
- You may want to unlock only some features during trial
- During payment failure, you may want to stop only CSV
Then if tenant.plan in {...} begins spreading everywhere, and later it becomes impossible to trace properly.
So from the beginning, I recommend the following policy:
- Treat plan names as product labels
- Perform actual feature checks using “feature keys”
- Manage usage limits separately as numeric values
- Treat subscription state independently from the plan
With this structure, even if you reorganize plans later, fewer changes are needed on the application side.
4. Basic data model: separate Tenant, Subscription, and PlanFeature
Let us first create a minimum conceptual model.
4.1 Tenant
A tenant is the customer organization itself.
# app/models/tenant.py
from pydantic import BaseModel
class Tenant(BaseModel):
id: int
name: str
is_active: bool = True
4.2 Subscription
The actual contract should be stored separately.
# app/models/subscription.py
from pydantic import BaseModel
from typing import Literal
from datetime import datetime
PlanCode = Literal["free", "starter", "pro", "enterprise"]
SubscriptionStatus = Literal[
"trialing",
"active",
"past_due",
"paused",
"canceled",
"expired",
]
class Subscription(BaseModel):
tenant_id: int
plan_code: PlanCode
status: SubscriptionStatus
current_period_end: datetime | None = None
cancel_at_period_end: bool = False
trial_ends_at: datetime | None = None
4.3 Plan features
The mapping between plans and features should be held as a separate table or configuration.
# app/models/plan_feature.py
from pydantic import BaseModel
class PlanFeature(BaseModel):
plan_code: str
feature_key: str
enabled: bool = True
limit_value: int | None = None
If you structure it this way, then even when you want to add a new feature to the pro plan, you only need to update the mapping between the plan and the feature.
5. Define feature keys first: fix machine-oriented names before human-oriented names
If you are going to implement feature restrictions, you first need a machine-readable way to express what is being restricted.
For example, keys like these:
project.createproject.export_csvaudit_log.viewapi.accesswebhook.createmember.max_countstorage.max_bytes
What matters here is separating boolean-style features from limit-style features.
Boolean features
- Usable / not usable
Examples:
audit_log.viewproject.export_csv
Limit-based features
- Up to how many, how many people, how many GB
Examples:
member.max_count = 5storage.max_bytes = 1073741824
Without this distinction, the design becomes very difficult to manage later.
6. Basic FastAPI policy: resolve subscription information through dependencies
In FastAPI, just like authenticated users and tenant information, it is easier to keep things organized if you obtain the current subscription through a dependency.
6.1 Get the current tenant
Assume, in line with previous articles, that the current tenant ID is already resolved.
# app/deps/tenant.py
def get_current_tenant_id() -> int:
return 10
6.2 Get the current subscription
# app/deps/subscription.py
from fastapi import Depends, HTTPException, status
from app.deps.tenant import get_current_tenant_id
from app.models.subscription import Subscription
def get_current_subscription(
tenant_id: int = Depends(get_current_tenant_id),
) -> Subscription:
# In reality, fetch from DB or cache
if tenant_id == 10:
return Subscription(
tenant_id=10,
plan_code="pro",
status="active",
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="subscription not found",
)
With this, each router and service layer can consistently refer to “the current tenant’s subscription information.”
7. Turn feature checks into functions: has_feature() and get_limit()
To avoid scattering plan names throughout the app, prepare feature-checking functions.
# app/services/plan_service.py
from app.models.subscription import Subscription
FEATURE_MATRIX = {
"free": {
"project.export_csv": False,
"audit_log.view": False,
"api.access": False,
"member.max_count": 3,
"storage.max_bytes": 100 * 1024 * 1024,
},
"pro": {
"project.export_csv": True,
"audit_log.view": True,
"api.access": True,
"member.max_count": 20,
"storage.max_bytes": 5 * 1024 * 1024 * 1024,
},
"enterprise": {
"project.export_csv": True,
"audit_log.view": True,
"api.access": True,
"member.max_count": 9999,
"storage.max_bytes": 100 * 1024 * 1024 * 1024,
},
}
# app/services/plan_service.py (continued)
def has_feature(subscription: Subscription, feature_key: str) -> bool:
plan_features = FEATURE_MATRIX.get(subscription.plan_code, {})
value = plan_features.get(feature_key, False)
return bool(value) if isinstance(value, bool) else True
def get_limit(subscription: Subscription, feature_key: str) -> int | None:
plan_features = FEATURE_MATRIX.get(subscription.plan_code, {})
value = plan_features.get(feature_key)
return value if isinstance(value, int) else None
This implementation is only a minimum example, but the important point is to centralize the decision logic in one place.
8. Always include subscription state in the decision: do not assume only active means usable
A common omission in plan checks is subscription state.
For example, even with a pro plan, if the state is past_due, you may need to distinguish between “features that may continue” and “features that should stop.”
So instead of looking only at has_feature(), also check whether the subscription is in a usable state.
# app/services/subscription_policy.py
from app.models.subscription import Subscription
ACTIVE_LIKE = {"trialing", "active"}
def is_subscription_usable(subscription: Subscription) -> bool:
return subscription.status in ACTIVE_LIKE
However, in practice, past_due is often not stopped immediately.
For example, there may be a 3-day grace period after payment failure during which read-only access is allowed, while write operations are stopped.
For that reason, it is useful to split status judgments by operation type, like this:
# app/services/subscription_policy.py
def can_use_read_features(subscription: Subscription) -> bool:
return subscription.status in {"trialing", "active", "past_due"}
def can_use_write_features(subscription: Subscription) -> bool:
return subscription.status in {"trialing", "active"}
def can_use_export_features(subscription: Subscription) -> bool:
return subscription.status in {"active"}
This structure lets you flexibly change behavior during payment failure.
9. Express “feature required” through dependencies
In a FastAPI-like way, using dependencies for required features makes the router’s intent easier to read.
9.1 Restriction for a feature-flag type feature
# app/deps/plan_permissions.py
from fastapi import Depends, HTTPException, status
from app.deps.subscription import get_current_subscription
from app.services.plan_service import has_feature
from app.services.subscription_policy import can_use_write_features
def require_csv_export_feature(
subscription = Depends(get_current_subscription),
):
if not can_use_write_features(subscription):
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail="subscription is not usable for export",
)
if not has_feature(subscription, "project.export_csv"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="csv export is not available for this plan",
)
return subscription
9.2 Use it in the router
# app/api/v1/routers/projects.py
from fastapi import APIRouter, Depends
from app.deps.plan_permissions import require_csv_export_feature
router = APIRouter(prefix="/projects", tags=["projects"])
@router.get("/export")
def export_projects_csv(
subscription = Depends(require_csv_export_feature),
):
return {"status": "export started", "plan": subscription.plan_code}
Now the intent that “CSV export has plan conditions” can be read directly from the router.
10. Limit-based restrictions: safely handling count, capacity, and seat limits
Not only boolean-style features, but also limits on counts and capacity are very important in SaaS.
10.1 Example: member count limit
If you want “free allows up to 3 people” and “pro allows up to 20 people,” you should check the limit before adding.
# app/services/member_policy.py
from app.models.subscription import Subscription
from app.services.plan_service import get_limit
def can_add_member(subscription: Subscription, current_member_count: int) -> bool:
limit = get_limit(subscription, "member.max_count")
if limit is None:
return True
return current_member_count < limit
10.2 Use it in the service layer
# app/services/member_service.py
from fastapi import HTTPException, status
from app.models.subscription import Subscription
from app.services.member_policy import can_add_member
def invite_member(subscription: Subscription, current_member_count: int):
if not can_add_member(subscription, current_member_count):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="member limit reached",
)
return {"status": "invited"}
10.3 Example: storage capacity limits
This also pairs well with file-upload design, but the same logic applies to storage limits.
- Aggregate current usage
- Add the expected size of the new upload
- Reject if it would exceed
storage.max_bytes
By putting that check in the upload service layer, consistency with the plan can be maintained.
11. Add-on design: preparing for reality that cannot be expressed by plans alone
In real SaaS products, “additional purchases” like the following are common:
- Extra member pack
- Additional storage
- Extended audit log retention
- Additional API access quota
- Extra webhook slots
In such cases, plans alone are not enough.
That is why a design that includes addons in addition to subscription becomes useful.
# app/models/addon.py
from pydantic import BaseModel
class Addon(BaseModel):
tenant_id: int
addon_key: str
quantity: int = 1
For example, you may calculate the final storage.max_bytes like this:
- Base value from the plan
- Added value from add-ons
- Override from campaigns or special contracts
If you centralize this in a function like effective_limit(), it becomes much easier to change later.
12. Trial design: thinking about it from the start makes things easier later
Trials affect revenue too, so it is worth organizing them from the start.
Common policies
- During trial, unlock part or all of
pro-equivalent features - Once
trial_ends_athas passed, move toexpiredorfree-equivalent state - Clearly decide whether billing starts automatically after trial or waits for manual upgrade
What matters in practice is not creating separate logic for trial judgment and regular plan judgment.
A recommended pattern is to treat trial as a normal subscription with status="trialing" and absorb the behavior in the decision functions.
13. Webhook-based state transitions: how to reflect billing events into the app
In SaaS, subscription state often changes through webhooks from payment or billing systems.
The important point here is to separate responsibilities so that “when an external event arrives, the app updates its internal Subscription state.”
13.1 Typical events
- Subscription created
- Trial started
- Renewal succeeded
- Payment failed
- Payment confirmed
- Cancellation scheduled
- Immediate cancellation
- Plan changed
13.2 Hold the app’s own “source of truth” state
Rather than querying the external service in real time every time, it is more stable to keep the current Subscription state inside the application.
Reasons include:
- API responses become faster
- Decisions still work during temporary external outages
- Easier to test
- Easier to connect state changes with audit logs
So webhook handling should follow a flow like this:
- Receive the event
- Verify legitimacy
- Update
Subscription - Emit audit logs or domain events if needed
14. UX design during payment failure: stop everything, or stop things in stages?
Payment failure affects both product experience and revenue.
If this part is vague, operations become very confusing.
Common staged control
- Immediately after becoming
past_due- Reading allowed, writing blocked
- After the grace period ends
- Login allowed, major features blocked
- Full suspension
- Everything heavily restricted except the subscription-management screen
To reflect this in FastAPI, it is easier to split the decision granularity as shown earlier:
can_use_read_featurescan_use_write_featurescan_use_export_features
That structure is much easier to work with.
15. Integration with audit logs: always record billing and plan changes
This also connects with the previous article’s context, but changes around plans and billing are extremely important audit-log targets.
Examples of events you want to record:
subscription.createdsubscription.plan_changedsubscription.past_duesubscription.canceledsubscription.reactivatedaddon.addedaddon.removed
These events are useful both for customer support and incident investigation.
To explain “Why was CSV export available yesterday but not today?”, you need the history of subscription state.
16. Combine with RBAC/ABAC: treat plan restrictions as part of authorization
The important connection with the previous RBAC/ABAC article is that plan restrictions should also be treated as part of authorization.
For example, whether CSV export is allowed may be the result of the following stacked conditions:
- RBAC
vieweris not allowed
- ABAC
- Must belong to the same tenant
- Subscription state
- Must be
active
- Must be
- Plan feature
project.export_csvmust be enabled
So the final authorization becomes a composition like this:
Authenticated
AND belongs to tenant
AND required role
AND required attribute conditions
AND subscription state OK
AND plan feature OK
Thinking in this order makes it surprisingly easy to organize even though it looks complex.
17. Common failure patterns
17.1 tenant.plan == "pro" is scattered across the entire codebase
Later, you will no longer be able to handle plan changes or exception contracts safely.
Always move it into functions or policies.
17.2 Not checking subscription state
Even a pro contract may be past_due or canceled.
You need to treat plan_code and status separately.
17.3 Restricting only in the UI without protecting the backend
Hiding a button means nothing if the API can still be called.
The final decision must always be enforced on the FastAPI side.
17.4 Hardcoding limit values as constants
If “up to 5” and “up to 10” are scattered around, plan changes will create accidents.
Centralize them in functions like get_limit().
17.5 Using webhook events directly for business decisions
If you depend only on the immediate state when the external event arrives, the design becomes unstable.
It is safer to store a normalized Subscription state inside the application.
18. Testing strategy: plan limitations break more easily than you think
The combination of plans and billing states is more fragile than it looks.
That is why it is best to protect it with both unit tests and API tests.
18.1 Minimum tests you should have
freecannot export CSVprocan export CSV- Even
procannot export whilepast_due freecannot add members after reaching the limit- Enterprise can view audit logs
- Scheduled cancellation still allows use during the active period
- Behavior after trial end is exactly as intended
18.2 Unit test example
# tests/test_plan_service.py
from app.models.subscription import Subscription
from app.services.plan_service import has_feature, get_limit
def test_pro_has_csv_export():
subscription = Subscription(tenant_id=10, plan_code="pro", status="active")
assert has_feature(subscription, "project.export_csv") is True
def test_free_member_limit():
subscription = Subscription(tenant_id=10, plan_code="free", status="active")
assert get_limit(subscription, "member.max_count") == 3
18.3 Subscription state test example
# tests/test_subscription_policy.py
from app.models.subscription import Subscription
from app.services.subscription_policy import can_use_export_features
def test_past_due_cannot_export():
subscription = Subscription(tenant_id=10, plan_code="pro", status="past_due")
assert can_use_export_features(subscription) is False
If you keep these pure-function tests reasonably thick, plan revisions become much safer.
19. Reader-specific roadmap
For individual developers and learners
- Start with only
freeandpro - Avoid hardcoding
tenant.plan, and createhas_feature() - Handle at least one limit value, such as member count, via
get_limit() - Add 403- or 402-style restrictions to major APIs
For engineers in small teams
- Create a “feature list” before a pricing table
- Separate the model into
plan_code,status, andlimit - Use dependencies to obtain the current subscription information
- Move router-level inline checks into policy functions
- Record subscription-change events in audit logs
For SaaS teams and startups
- Organize responsibilities among plans, add-ons, and billing states
- Synchronize
Subscriptionstate through webhooks - Explicitly document grace periods and suspension policies
- Integrate plan restrictions into the same authorization layer as RBAC/ABAC
- Reflect them in OpenAPI, admin screens, and customer-support procedures too
References
- FastAPI Documentation
- FastAPI Dependencies
- FastAPI Security
- Pydantic Documentation
- OWASP Authorization Cheat Sheet
Conclusion
- SaaS-oriented plan design is not just a pricing table, but the design of authorization, limit management, and state management across the entire application.
- In FastAPI, the design becomes much more stable if you resolve current subscription information through dependencies and centralize feature checks and limit checks into policy functions.
- Treat
plan_codeandstatusseparately, and if needed, allow add-ons and exception contracts to be layered on top. That makes the design much stronger against later change. - It is important not only to control display in the UI, but also to enforce feature restrictions on the backend, and protect them through audit logs and tests.
- You do not need to build a perfect billing foundation from day one, but if you separate “feature keys,” “limits,” and “subscription states” from the start, the system becomes far more resilient as it grows.
A natural next article in this flow would be something like “FastAPI design patterns for internal admin-panel APIs” or “Safe implementation of webhook receiver APIs.”

