Stripe Subscription Setup (Step-by-Step)
The essential playbook for implementing stripe subscription setup (step-by-step) in your SaaS.
This page outlines a production-safe Stripe subscription setup for SaaS apps. It covers the minimum working flow: create products and recurring prices, start a Checkout Session, store the Stripe customer and subscription IDs, process webhooks, and gate access based on subscription status. Use this when you need a clean baseline that works for MVPs and can scale without rewriting billing later.
Quick Fix / Quick Setup
# 1) Install Stripe SDK
pip install stripe
# 2) Set required env vars
export STRIPE_SECRET_KEY=sk_test_xxx
export STRIPE_WEBHOOK_SECRET=whsec_xxx
export STRIPE_PRICE_ID=price_xxx
export STRIPE_SUCCESS_URL='https://app.example.com/billing/success?session_id={CHECKOUT_SESSION_ID}'
export STRIPE_CANCEL_URL='https://app.example.com/billing/cancel'# 3) Minimal FastAPI checkout endpoint
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
import os, stripe
app = FastAPI()
stripe.api_key = os.environ['STRIPE_SECRET_KEY']
@app.post('/billing/checkout')
async def create_checkout(request: Request):
body = await request.json()
user_id = body['user_id']
user_email = body['email']
session = stripe.checkout.Session.create(
mode='subscription',
line_items=[{'price': os.environ['STRIPE_PRICE_ID'], 'quantity': 1}],
customer_email=user_email,
success_url=os.environ['STRIPE_SUCCESS_URL'],
cancel_url=os.environ['STRIPE_CANCEL_URL'],
client_reference_id=str(user_id),
allow_promotion_codes=True,
)
return {'url': session.url}
@app.post('/stripe/webhook')
async def stripe_webhook(request: Request):
payload = await request.body()
sig = request.headers.get('stripe-signature')
try:
event = stripe.Webhook.construct_event(
payload, sig, os.environ['STRIPE_WEBHOOK_SECRET']
)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
if event['type'] == 'checkout.session.completed':
session = event['data']['object']
# save stripe_customer_id, stripe_subscription_id, price_id, status
# map client_reference_id back to your user
elif event['type'] in ['customer.subscription.updated', 'customer.subscription.deleted']:
sub = event['data']['object']
# update local subscription status from sub['status']
elif event['type'] == 'invoice.paid':
invoice = event['data']['object']
# mark billing period active if needed
elif event['type'] == 'invoice.payment_failed':
invoice = event['data']['object']
# flag account for payment issue / dunning flow
return JSONResponse({'received': True})# 4) Forward webhooks locally
stripe listen --forward-to localhost:8000/stripe/webhook
# 5) Trigger a test event
stripe trigger checkout.session.completedDo not grant or revoke paid access only from the frontend redirect. Use Stripe webhooks as the source of truth. Store stripe_customer_id and stripe_subscription_id in your database.
What’s happening
Stripe subscriptions usually follow this flow:
- User selects a plan.
- Your backend creates a Stripe Checkout Session.
- Stripe creates or reuses a customer.
- Stripe starts the subscription after payment.
- Stripe sends webhook events to your backend.
- Your app updates local billing state.
- Your backend gates paid features from that local state.
Your app should maintain a local billing record tied to your user or tenant:
user_idortenant_idstripe_customer_idstripe_subscription_idprice_idstatuscurrent_period_endcancel_at_period_end
The reliable source of truth is the webhook stream, not success-page redirects and not client-side state.
For access control, map Stripe subscription states into app states such as:
activetrialingpast_duecanceledunpaidincomplete
Process Flow
Step-by-step implementation
1) Create products and recurring prices
Create Stripe products and recurring prices in the Stripe Dashboard or via API.
Rules:
- Use one price ID per plan and environment.
- Keep test and live IDs separate.
- Do not trust a raw client-provided Stripe price ID without server-side validation.
Example plan mapping:
PLAN_TO_PRICE = {
"starter_monthly": os.environ["STRIPE_PRICE_STARTER_MONTHLY"],
"pro_monthly": os.environ["STRIPE_PRICE_PRO_MONTHLY"],
}2) Set environment variables
Add environment variables for:
export STRIPE_SECRET_KEY=sk_test_xxx
export STRIPE_PUBLISHABLE_KEY=pk_test_xxx
export STRIPE_WEBHOOK_SECRET=whsec_xxx
export STRIPE_PRICE_STARTER_MONTHLY=price_xxx
export STRIPE_PRICE_PRO_MONTHLY=price_xxx
export STRIPE_SUCCESS_URL='https://app.example.com/billing/success?session_id={CHECKOUT_SESSION_ID}'
export STRIPE_CANCEL_URL='https://app.example.com/billing/cancel'
export STRIPE_BILLING_PORTAL_RETURN_URL='https://app.example.com/settings/billing'3) Create local billing tables
Use normalized billing tables with unique constraints.
Example PostgreSQL schema:
create table billing_customers (
id bigserial primary key,
user_id bigint not null unique,
stripe_customer_id text not null unique,
created_at timestamptz not null default now()
);
create table subscriptions (
id bigserial primary key,
user_id bigint not null,
stripe_subscription_id text not null unique,
stripe_customer_id text not null,
stripe_price_id text,
plan_key text,
status text not null,
current_period_start timestamptz,
current_period_end timestamptz,
cancel_at_period_end boolean not null default false,
trial_end timestamptz,
quantity integer not null default 1,
raw_payload_json jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table billing_events (
id bigserial primary key,
stripe_event_id text not null unique,
type text not null,
processed_at timestamptz,
payload jsonb
);4) Create or reuse the Stripe customer
If you already have a billing customer stored locally, reuse it. If not, create one explicitly.
def get_or_create_customer(user_id: int, email: str, db):
row = db.fetch_one(
"select stripe_customer_id from billing_customers where user_id = %s",
[user_id],
)
if row:
return row["stripe_customer_id"]
customer = stripe.Customer.create(
email=email,
metadata={"user_id": str(user_id)}
)
db.execute(
"""
insert into billing_customers (user_id, stripe_customer_id)
values (%s, %s)
""",
[user_id, customer["id"]],
)
return customer["id"]5) Create a backend Checkout Session endpoint
Use mode=subscription. Pass your internal user ID via client_reference_id or metadata. Validate the plan key on the server.
from fastapi import FastAPI, Request, HTTPException
import os, stripe
app = FastAPI()
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
PLAN_TO_PRICE = {
"starter_monthly": os.environ["STRIPE_PRICE_STARTER_MONTHLY"],
"pro_monthly": os.environ["STRIPE_PRICE_PRO_MONTHLY"],
}
@app.post("/billing/checkout")
async def billing_checkout(request: Request):
body = await request.json()
user_id = int(body["user_id"])
email = body["email"]
plan_key = body["plan_key"]
if plan_key not in PLAN_TO_PRICE:
raise HTTPException(status_code=400, detail="invalid plan")
# Replace with your real DB handle
db = request.app.state.db
existing = db.fetch_one(
"""
select status from subscriptions
where user_id = %s
order by created_at desc
limit 1
""",
[user_id],
)
if existing and existing["status"] in ("active", "trialing", "past_due"):
raise HTTPException(status_code=409, detail="subscription already exists")
stripe_customer_id = get_or_create_customer(user_id, email, db)
session = stripe.checkout.Session.create(
mode="subscription",
customer=stripe_customer_id,
line_items=[{
"price": PLAN_TO_PRICE[plan_key],
"quantity": 1,
}],
success_url=os.environ["STRIPE_SUCCESS_URL"],
cancel_url=os.environ["STRIPE_CANCEL_URL"],
client_reference_id=str(user_id),
metadata={
"user_id": str(user_id),
"plan_key": plan_key,
},
allow_promotion_codes=True,
)
return {"url": session.url}6) Redirect to Stripe Checkout
Return session.url to your frontend and redirect the user there.
Do not mark the user as paid on the success page. The success page is only UX.
7) Expose and verify the webhook endpoint
Reject unsigned or invalid payloads. Use Stripe’s webhook secret, not your API key.
from fastapi.responses import JSONResponse
@app.post("/stripe/webhook")
async def stripe_webhook(request: Request):
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
db = request.app.state.db
try:
event = stripe.Webhook.construct_event(
payload,
sig_header,
os.environ["STRIPE_WEBHOOK_SECRET"],
)
except Exception as e:
raise HTTPException(status_code=400, detail=f"invalid webhook: {e}")
event_id = event["id"]
event_type = event["type"]
existing = db.fetch_one(
"select stripe_event_id from billing_events where stripe_event_id = %s",
[event_id],
)
if existing:
return JSONResponse({"received": True, "duplicate": True})
db.execute(
"""
insert into billing_events (stripe_event_id, type, payload, processed_at)
values (%s, %s, %s::jsonb, now())
""",
[event_id, event_type, payload.decode("utf-8")],
)
data = event["data"]["object"]
if event_type == "checkout.session.completed":
await handle_checkout_completed(data, db)
elif event_type in (
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
):
await handle_subscription_event(data, db)
elif event_type == "invoice.paid":
await handle_invoice_paid(data, db)
elif event_type == "invoice.payment_failed":
await handle_invoice_failed(data, db)
return JSONResponse({"received": True})8) Save subscription linkage on checkout completion
Use checkout.session.completed to tie the Stripe customer and subscription back to your internal user.
from datetime import datetime, timezone
def ts_to_dt(ts):
if not ts:
return None
return datetime.fromtimestamp(ts, tz=timezone.utc)
async def handle_checkout_completed(session, db):
user_id = int(session["client_reference_id"])
stripe_customer_id = session.get("customer")
stripe_subscription_id = session.get("subscription")
if stripe_customer_id:
db.execute(
"""
insert into billing_customers (user_id, stripe_customer_id)
values (%s, %s)
on conflict (user_id) do update
set stripe_customer_id = excluded.stripe_customer_id
""",
[user_id, stripe_customer_id],
)
if stripe_subscription_id:
subscription = stripe.Subscription.retrieve(
stripe_subscription_id,
expand=["items.data.price"],
)
item = subscription["items"]["data"][0] if subscription["items"]["data"] else None
price_id = item["price"]["id"] if item and item.get("price") else None
db.execute(
"""
insert into subscriptions (
user_id, stripe_subscription_id, stripe_customer_id,
stripe_price_id, status, current_period_start, current_period_end,
cancel_at_period_end, trial_end, quantity, raw_payload_json, updated_at
)
values (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 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,
cancel_at_period_end = excluded.cancel_at_period_end,
trial_end = excluded.trial_end,
quantity = excluded.quantity,
raw_payload_json = excluded.raw_payload_json,
updated_at = now()
""",
[
user_id,
subscription["id"],
subscription["customer"],
price_id,
subscription["status"],
ts_to_dt(subscription.get("current_period_start")),
ts_to_dt(subscription.get("current_period_end")),
subscription.get("cancel_at_period_end", False),
ts_to_dt(subscription.get("trial_end")),
item.get("quantity", 1) if item else 1,
stripe.util.json.dumps(subscription),
],
)9) Keep local state synced from subscription lifecycle events
Handle creation, updates, and deletion.
async def handle_subscription_event(sub, db):
stripe_subscription_id = sub["id"]
stripe_customer_id = sub["customer"]
row = db.fetch_one(
"select user_id from billing_customers where stripe_customer_id = %s",
[stripe_customer_id],
)
if not row:
return
user_id = row["user_id"]
item = sub["items"]["data"][0] if sub["items"]["data"] else None
price_id = item["price"]["id"] if item and item.get("price") else None
db.execute(
"""
insert into subscriptions (
user_id, stripe_subscription_id, stripe_customer_id,
stripe_price_id, status, current_period_start, current_period_end,
cancel_at_period_end, trial_end, quantity, raw_payload_json, updated_at
)
values (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, now())
on conflict (stripe_subscription_id) do update set
stripe_price_id = excluded.stripe_price_id,
status = excluded.status,
current_period_start = excluded.current_period_start,
current_period_end = excluded.current_period_end,
cancel_at_period_end = excluded.cancel_at_period_end,
trial_end = excluded.trial_end,
quantity = excluded.quantity,
raw_payload_json = excluded.raw_payload_json,
updated_at = now()
""",
[
user_id,
stripe_subscription_id,
stripe_customer_id,
price_id,
sub["status"],
ts_to_dt(sub.get("current_period_start")),
ts_to_dt(sub.get("current_period_end")),
sub.get("cancel_at_period_end", False),
ts_to_dt(sub.get("trial_end")),
item.get("quantity", 1) if item else 1,
stripe.util.json.dumps(sub),
],
)10) Handle invoice events
Use invoice events for payment issue visibility and enforcement.
async def handle_invoice_paid(invoice, db):
stripe_subscription_id = invoice.get("subscription")
if not stripe_subscription_id:
return
db.execute(
"""
update subscriptions
set updated_at = now()
where stripe_subscription_id = %s
""",
[stripe_subscription_id],
)
async def handle_invoice_failed(invoice, db):
stripe_subscription_id = invoice.get("subscription")
if not stripe_subscription_id:
return
db.execute(
"""
update subscriptions
set updated_at = now()
where stripe_subscription_id = %s
""",
[stripe_subscription_id],
)
# Optional:
# - notify user
# - mark account as payment_issue
# - start dunning flow11) Map Stripe status to access
Define this once and use it in your backend authorization.
ALLOWED_ACCESS_STATUSES = {"active", "trialing"}
def has_paid_access(subscription_status: str) -> bool:
return subscription_status in ALLOWED_ACCESS_STATUSESExample check:
def user_has_billing_access(user_id: int, db) -> bool:
row = db.fetch_one(
"""
select status
from subscriptions
where user_id = %s
order by updated_at desc
limit 1
""",
[user_id],
)
return bool(row and has_paid_access(row["status"]))12) Add billing management
For MVPs, Stripe Customer Portal is usually enough.
@app.post("/billing/portal")
async def create_billing_portal(request: Request):
body = await request.json()
user_id = int(body["user_id"])
db = request.app.state.db
row = db.fetch_one(
"select stripe_customer_id from billing_customers where user_id = %s",
[user_id],
)
if not row:
raise HTTPException(status_code=404, detail="billing customer not found")
session = stripe.billing_portal.Session.create(
customer=row["stripe_customer_id"],
return_url=os.environ["STRIPE_BILLING_PORTAL_RETURN_URL"],
)
return {"url": session.url}13) Test locally and in staging
Use Stripe CLI locally. Use separate webhook secrets and test price IDs in staging.
stripe login
stripe listen --forward-to localhost:8000/stripe/webhook
stripe trigger checkout.session.completed
stripe events list
stripe customers list --limit 10
stripe subscriptions list --limit 10
curl -X POST http://localhost:8000/billing/checkout \
-H 'Content-Type: application/json' \
-d '{"user_id":1,"email":"user@example.com","plan_key":"starter_monthly"}'
python -c "import os, stripe; stripe.api_key=os.environ['STRIPE_SECRET_KEY']; print(stripe.Price.retrieve(os.environ['STRIPE_PRICE_STARTER_MONTHLY']))"14) Go live safely
Before production:
- Create live products and live recurring prices.
- Update live API keys and webhook secrets.
- Confirm webhook endpoint is publicly reachable.
- Verify billing rows are written in production.
- Verify duplicate webhook deliveries are idempotent.
- Verify canceled and failed-payment states update access correctly.
If your app is containerized, validate env injection and network reachability with Docker Production Setup for SaaS.
Common causes
- Using the wrong price ID or mixing test and live Stripe keys.
- Granting access from the frontend success redirect instead of webhook-confirmed state.
- Webhook endpoint not publicly reachable or using the wrong webhook secret.
- Not storing
stripe_customer_idandstripe_subscription_id, making later sync and support difficult. - Missing idempotency, causing duplicate records or repeated entitlement changes on webhook retries.
- Creating a new Stripe customer for every checkout instead of reusing the existing customer.
- Not handling
customer.subscription.updatedorcustomer.subscription.deleted, leaving local status stale. - Plan lookup trusts arbitrary client input instead of server-side validated plan keys.
- Database write failures inside webhook handlers with no retry or dead-letter handling.
- Using local subscription state for gating but never backfilling it from Stripe on recovery.
Debugging tips
Start with the shortest path:
- Confirm the Checkout Session is created with the expected key and recurring price ID.
- Confirm Stripe delivers events to your webhook endpoint.
- Confirm signature verification passes.
- Confirm your database writes succeed.
- Confirm your backend access check reads the latest subscription state.
Useful commands:
stripe login
stripe listen --forward-to localhost:8000/stripe/webhook
stripe trigger checkout.session.completed
stripe events list
stripe customers list --limit 10
stripe subscriptions list --limit 10
curl -X POST http://localhost:8000/billing/checkout -H 'Content-Type: application/json' -d '{"user_id":1,"email":"user@example.com"}'
curl -i http://localhost:8000/stripe/webhook
python -c "import os, stripe; stripe.api_key=os.environ['STRIPE_SECRET_KEY']; print(stripe.Price.retrieve(os.environ['STRIPE_PRICE_ID']))"
grep -i stripe /var/log/app.logSpecific checks:
- If the success redirect works but your app still shows free plan, webhook processing is failing or local state is not updating.
- If Stripe shows an active subscription but your database does not, inspect Stripe webhook delivery logs and your DB transaction logs.
- If events are duplicated, add idempotency based on
event.id. - If users can subscribe multiple times, check customer reuse and block duplicate purchases for already-active plans.
- If upgrades or downgrades behave unexpectedly, use subscription update APIs or Customer Portal instead of creating another subscription via Checkout.
subscription sequence diagram with event types labeled at each step.
For webhook reliability details, see Handling Webhooks Correctly. For lifecycle mapping, see Managing Subscription States.
Checklist
- ✓ Separate Stripe test and live keys, prices, and webhook secrets.
- ✓ Create one backend checkout endpoint per billing action or one generic endpoint with strict plan validation.
- ✓ Verify webhook signatures on every request.
- ✓ Store
stripe_customer_idandstripe_subscription_idlocally. - ✓ Process webhook events idempotently using event IDs.
- ✓ Map Stripe statuses to application access states.
- ✓ Block duplicate active subscriptions for the same user or tenant.
- ✓ Add logs for checkout creation, webhook receipt, signature failures, and DB writes.
- ✓ Provide a billing page or Stripe Customer Portal link.
- ✓ Test
checkout.session.completed,invoice.paid,invoice.payment_failed, and subscription cancellation before launch. - ✓ Confirm auth user IDs are stable and safe to use in billing linkage. See Implement User Authentication (Login/Register).
- ✓ Review launch readiness with Payment System Checklist.
FAQ
What is the minimum Stripe setup for subscriptions?
At minimum: a recurring product price, a backend Checkout Session endpoint, a verified webhook endpoint, local storage for stripe_customer_id and stripe_subscription_id, and backend feature gating tied to subscription status.
Should I store the full Stripe payload in my database?
Store the normalized fields you query often. Optionally store raw event payloads or selected JSON for audit and debugging, but do not depend only on raw blobs for application logic.
How do I prevent duplicate subscriptions?
Reuse the same Stripe customer for the same user or tenant, check local billing state before creating a new checkout, and disable purchase actions for already-active plans.
Which webhook events matter most?
Start with:
checkout.session.completedcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paidinvoice.payment_failed
Add more only when your billing logic requires them.
Can I support upgrades and downgrades with the same setup?
Yes, but plan changes are usually better handled with subscription update APIs or Stripe Customer Portal rather than always creating a brand-new Checkout subscription.
Should I use Stripe Checkout or build a custom card form?
For most MVPs and small SaaS apps, use Stripe Checkout first. It is faster to ship and reduces PCI scope.
Should I create a Stripe customer before checkout?
Yes, if you already have a user record and want stable customer mapping. Otherwise Stripe can create one during Checkout and you store it after webhook processing.
Can I enable access on the success page?
No. The success page can be reached before your webhook processing completes. Always update access from webhook-confirmed state.
What subscription statuses should count as paid?
Usually active and trialing. Treat past_due, unpaid, canceled, and incomplete based on your business rules. See Managing Subscription States.
Do I need both checkout.session.completed and customer.subscription.updated?
Yes. checkout.session.completed is useful for initial linkage, while customer.subscription.updated keeps later lifecycle changes synced.
Final takeaway
A solid Stripe subscription setup is mostly backend state management: create Checkout Sessions safely, verify webhooks, store Stripe IDs, process events idempotently, and gate features from server-side subscription state. Keep the MVP flow simple, but make webhook handling and local billing records correct from day one.