Pricing Page Implementation
The essential playbook for implementing pricing page implementation in your SaaS.
Build a pricing page that is simple to maintain, tied directly to your billing system, and safe to ship. For small SaaS products, the main goal is to avoid hardcoded plan logic drifting away from Stripe configuration. Define plans in one place, render them consistently, and pass only validated Stripe price IDs into checkout.
Quick Fix / Quick Setup
Use a single server-side pricing config and only accept plan + interval from the client.
# pricing_config.py
from dataclasses import dataclass
import os
@dataclass
class Plan:
key: str
name: str
monthly_price_id: str | None
yearly_price_id: str | None
monthly_display: str
yearly_display: str
features: list[str]
popular: bool = False
PLANS = [
Plan(
key="starter",
name="Starter",
monthly_price_id=os.getenv("STRIPE_PRICE_STARTER_MONTHLY"),
yearly_price_id=os.getenv("STRIPE_PRICE_STARTER_YEARLY"),
monthly_display="$19/mo",
yearly_display="$190/yr",
features=["1 project", "Email support", "Basic analytics"]
),
Plan(
key="pro",
name="Pro",
monthly_price_id=os.getenv("STRIPE_PRICE_PRO_MONTHLY"),
yearly_price_id=os.getenv("STRIPE_PRICE_PRO_YEARLY"),
monthly_display="$49/mo",
yearly_display="$490/yr",
features=["10 projects", "Priority support", "Advanced analytics"],
popular=True,
),
]
PLAN_INDEX = {p.key: p for p in PLANS}
# app.py / routes.py
from flask import Flask, render_template, request, abort, jsonify
from pricing_config import PLAN_INDEX, PLANS
import stripe, os
app = Flask(__name__)
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
@app.get("/pricing")
def pricing():
interval = request.args.get("interval", "monthly")
if interval not in {"monthly", "yearly"}:
interval = "monthly"
return render_template("pricing.html", plans=PLANS, interval=interval)
@app.post("/create-checkout-session")
def create_checkout_session():
data = request.get_json(force=True)
plan_key = data.get("plan")
interval = data.get("interval", "monthly")
plan = PLAN_INDEX.get(plan_key)
if not plan:
abort(400, "invalid plan")
price_id = plan.monthly_price_id if interval == "monthly" else plan.yearly_price_id
if not price_id:
abort(400, "missing price id")
session = stripe.checkout.Session.create(
mode="subscription",
line_items=[{"price": price_id, "quantity": 1}],
success_url=os.getenv("APP_URL") + "/billing/success?session_id={CHECKOUT_SESSION_ID}",
cancel_url=os.getenv("APP_URL") + "/pricing",
allow_promotion_codes=True,
)
return jsonify({"url": session.url})Keep plan metadata in code or your database, but treat Stripe price IDs as the source of truth for billing. Do not trust client-submitted amounts, plan names, or feature entitlements.
What’s happening
A pricing page is both a marketing page and a billing entry point.
Most implementation bugs happen because plan definitions exist in multiple places:
- frontend pricing text
- backend entitlement logic
- Stripe products and prices
- account upgrade flow
A stable implementation uses:
- an internal plan key like
starter,pro,business - a trusted mapping from plan key + interval to Stripe Price ID
- server-side checkout session creation
- local subscription state synced after successful checkout
If your pricing page is only visual and not connected to backend validation, billing drift is likely.
Process Flow
Step-by-step implementation
1. Define plans once
Store plans in one config file or database table.
Minimum fields:
- internal key
- display name
- monthly Stripe price ID
- yearly Stripe price ID
- feature list
- flags like
popular,deprecated,contact_sales
Example:
# pricing_config.py
from dataclasses import dataclass
import os
@dataclass
class Plan:
key: str
name: str
monthly_price_id: str | None
yearly_price_id: str | None
monthly_display: str
yearly_display: str
features: list[str]
popular: bool = False
contact_sales: bool = False
PLANS = [
Plan(
key="free",
name="Free",
monthly_price_id=None,
yearly_price_id=None,
monthly_display="$0",
yearly_display="$0",
features=["1 user", "1 project", "Community support"],
),
Plan(
key="pro",
name="Pro",
monthly_price_id=os.getenv("STRIPE_PRICE_PRO_MONTHLY"),
yearly_price_id=os.getenv("STRIPE_PRICE_PRO_YEARLY"),
monthly_display="$29/mo",
yearly_display="$290/yr",
features=["Unlimited projects", "Priority email", "Advanced reporting"],
popular=True,
),
Plan(
key="enterprise",
name="Enterprise",
monthly_price_id=None,
yearly_price_id=None,
monthly_display="Custom",
yearly_display="Custom",
features=["SSO", "Priority SLA", "Custom limits"],
contact_sales=True,
),
]
PLAN_INDEX = {p.key: p for p in PLANS}Use internal plan keys for application permissions. Do not use Stripe product names as entitlement keys.
2. Render the pricing page from the plan source
Do not hardcode plan cards in HTML with duplicated numbers.
@app.get("/pricing")
def pricing():
interval = request.args.get("interval", "monthly")
if interval not in {"monthly", "yearly"}:
interval = "monthly"
return render_template("pricing.html", plans=PLANS, interval=interval)Example Jinja template:
<!-- templates/pricing.html -->
<h1>Pricing</h1>
<div>
<a href="/pricing?interval=monthly">Monthly</a>
<a href="/pricing?interval=yearly">Yearly</a>
</div>
<div class="plans">
{% for plan in plans %}
<div class="plan-card">
<h2>{{ plan.name }}</h2>
{% if interval == "yearly" %}
<p>{{ plan.yearly_display }}</p>
{% else %}
<p>{{ plan.monthly_display }}</p>
{% endif %}
{% if plan.popular %}
<div>Most Popular</div>
{% endif %}
<ul>
{% for feature in plan.features %}
<li>{{ feature }}</li>
{% endfor %}
</ul>
{% if plan.contact_sales %}
<a href="/contact-sales">Contact sales</a>
{% elif plan.key == "free" %}
<a href="/signup">Start free</a>
{% else %}
<button
class="checkout-btn"
data-plan="{{ plan.key }}"
data-interval="{{ interval }}">
Choose {{ plan.name }}
</button>
{% endif %}
</div>
{% endfor %}
</div>
<script>
document.querySelectorAll(".checkout-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
const res = await fetch("/create-checkout-session", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
plan: btn.dataset.plan,
interval: btn.dataset.interval
})
});
if (!res.ok) {
alert("Unable to start checkout");
return;
}
const data = await res.json();
window.location.href = data.url;
});
});
</script>3. Only allow valid intervals
Do not accept arbitrary client values.
ALLOWED_INTERVALS = {"monthly", "yearly"}
interval = data.get("interval", "monthly")
if interval not in ALLOWED_INTERVALS:
abort(400, "invalid interval")If the UI has a billing toggle, ensure the display and checkout button both use the same chosen interval.
4. Create checkout sessions on the server
Never let the client select a raw Stripe price directly unless your server still validates it against your known plan registry.
Safer server route:
@app.post("/create-checkout-session")
def create_checkout_session():
data = request.get_json(force=True)
plan_key = data.get("plan")
interval = data.get("interval", "monthly")
if interval not in {"monthly", "yearly"}:
abort(400, "invalid interval")
plan = PLAN_INDEX.get(plan_key)
if not plan:
abort(400, "invalid plan")
if plan.contact_sales or plan.key == "free":
abort(400, "plan does not support checkout")
price_id = plan.monthly_price_id if interval == "monthly" else plan.yearly_price_id
if not price_id:
abort(400, "missing price id")
session = stripe.checkout.Session.create(
mode="subscription",
line_items=[{"price": price_id, "quantity": 1}],
success_url=os.getenv("APP_URL") + "/billing/success?session_id={CHECKOUT_SESSION_ID}",
cancel_url=os.getenv("APP_URL") + "/pricing?interval=" + interval,
allow_promotion_codes=True,
)
return jsonify({"url": session.url})5. Tie checkout to authenticated users when needed
If the user is logged in, attach checkout to their application identity and existing Stripe customer.
session = stripe.checkout.Session.create(
mode="subscription",
customer=current_user.stripe_customer_id if current_user.stripe_customer_id else None,
client_reference_id=str(current_user.id),
metadata={
"app_user_id": str(current_user.id),
"plan_key": plan.key,
"interval": interval,
},
line_items=[{"price": price_id, "quantity": 1}],
success_url=os.getenv("APP_URL") + "/billing/success?session_id={CHECKOUT_SESSION_ID}",
cancel_url=os.getenv("APP_URL") + "/pricing?interval=" + interval,
)If anonymous users can start checkout, define account linking before shipping. Common options:
- require login before checkout
- create account first, then checkout
- store a pre-signup token and reconcile after checkout
For small SaaS products, forcing signup/login first is usually simpler. See Implement User Authentication (Login/Register).
6. Sync billing state after success
Do not assume a successful redirect means local billing is updated.
Recommended pattern:
- checkout success page confirms the session
- Stripe webhook updates subscription state
- app stores canonical billing fields locally
Example success handler:
@app.get("/billing/success")
def billing_success():
session_id = request.args.get("session_id")
if not session_id:
abort(400, "missing session_id")
session = stripe.checkout.Session.retrieve(session_id)
return render_template("billing_success.html", session=session)You still need webhooks for durable sync. Related setup: Stripe Subscription Setup (Step-by-Step) and Managing Subscription States.
7. Keep feature comparison aligned with entitlement logic
If the pricing page says:
- 10 projects
- priority support
- advanced analytics
then your backend should enforce those exact limits or capabilities.
Recommended rule:
- marketing text comes from plan config
- entitlement checks reference internal plan key
- tests verify both plan-to-price mapping and plan-to-feature logic
Example entitlement shape:
PLAN_ENTITLEMENTS = {
"free": {"projects": 1, "analytics": "basic"},
"starter": {"projects": 3, "analytics": "basic"},
"pro": {"projects": 10, "analytics": "advanced"},
}8. Track pricing events
At minimum track:
- pricing page view
- interval toggle
- checkout click
- checkout completed
- cancel from checkout
This helps catch broken flows quickly after deploy.
9. Test the plan mapping
Add tests for:
- invalid plan key
- invalid interval
- missing yearly price ID
- free plan incorrectly entering checkout
- enterprise plan incorrectly entering checkout
- staging and production price ID separation
Example minimal test:
def test_invalid_plan_checkout(client):
res = client.post("/create-checkout-session", json={
"plan": "does-not-exist",
"interval": "monthly",
})
assert res.status_code == 400Process Flow
Common causes
- Hardcoded prices in templates do not match current Stripe prices.
- Monthly and yearly plan IDs are swapped or missing.
- Frontend sends a plan amount instead of a trusted plan key.
- Anonymous users reach checkout without a usable account-linking flow.
- Displayed feature list does not match backend entitlement enforcement.
- Success URL returns users to the app without syncing subscription state.
- Using Stripe product names as permission keys causes access bugs after renaming plans.
- Enterprise/contact-sales tier is shown as a checkout option without a valid Stripe price.
- Staging and production price IDs are mixed together.
- Billing interval toggle updates display text but not the actual checkout price ID.
Debugging tips
Check environment variables:
printenv | grep STRIPEList Stripe prices:
stripe prices list --limit 10List Stripe products:
stripe products list --limit 10Forward Stripe webhooks locally:
stripe listen --forward-to localhost:8000/webhooks/stripeTest checkout session creation:
curl -X POST http://localhost:8000/create-checkout-session \
-H 'Content-Type: application/json' \
-d '{"plan":"pro","interval":"monthly"}'Inspect loaded plan config:
python -c "from pricing_config import PLANS; print(PLANS)"Search for duplicated Stripe price references:
grep -R "STRIPE_PRICE_" .Check that the page is live:
curl -I https://yourdomain.com/pricingValidate example request payload:
jq . <<< '{"plan":"starter","interval":"yearly"}'Extra checks:
- compare rendered UI price with actual configured Price ID
- verify the selected interval reaches the backend unchanged
- inspect the resulting Stripe Checkout Session in dashboard
- confirm webhook events update your local subscription row
If deploy issues appear after release, continue with your monitoring and incident flow. Related pages: Managing Subscription States and SaaS Production Checklist.
Checklist
- ✓ Plan cards are rendered from one canonical plan config.
- ✓ Each paid plan has valid monthly and/or yearly Stripe price IDs.
- ✓ Backend validates plan key and interval before checkout session creation.
- ✓ Client never sends price amount or entitlement data as trusted input.
- ✓ Success and cancel URLs are correct for production domain.
- ✓ Anonymous and authenticated upgrade flows are handled intentionally.
- ✓ Displayed features match actual plan enforcement logic.
- ✓ Free tier, paid tier, and enterprise/contact-sales cases are all handled.
- ✓ Annual discount copy matches real Stripe billing configuration.
- ✓ Pricing page click events and checkout conversions are tracked.
- ✓ Tests cover invalid mappings and missing price IDs.
Related guides
- Stripe Subscription Setup (Step-by-Step)
- Free Trials and Upgrade Flow
- Managing Subscription States
- Implement User Authentication (Login/Register)
- SaaS Production Checklist
FAQ
How should I store pricing plan definitions?
Use a single server-side config source or database table keyed by an internal plan slug. Map each plan to Stripe price IDs for each interval.
Should I fetch prices live from Stripe on every page load?
Usually no for small SaaS apps. Store the approved Stripe price IDs in config and render stable display values. Sync intentionally when pricing changes.
What should happen when a logged-in user clicks Upgrade?
Create a server-side checkout session tied to the authenticated user and existing Stripe customer if one exists. Do not create duplicate customers unnecessarily.
How do I handle annual pricing on the same page?
Use a billing interval toggle and map the selected interval to a separate Stripe price ID. Keep both display text and checkout flow aligned.
What is the biggest implementation mistake?
Letting pricing copy, checkout price IDs, and entitlement logic evolve independently. Centralize plan configuration and validate on the backend.
Final takeaway
A good pricing page is not just UI. It is a controlled mapping layer between plan marketing, Stripe price IDs, and your app’s entitlement logic. Keep plan definitions centralized, validate everything on the server, and make checkout entry paths explicit.