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

bash
# 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'
python
# 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})
bash
# 4) Forward webhooks locally
stripe listen --forward-to localhost:8000/stripe/webhook

# 5) Trigger a test event
stripe trigger checkout.session.completed

Do 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:

  1. User selects a plan.
  2. Your backend creates a Stripe Checkout Session.
  3. Stripe creates or reuses a customer.
  4. Stripe starts the subscription after payment.
  5. Stripe sends webhook events to your backend.
  6. Your app updates local billing state.
  7. Your backend gates paid features from that local state.

Your app should maintain a local billing record tied to your user or tenant:

  • user_id or tenant_id
  • stripe_customer_id
  • stripe_subscription_id
  • price_id
  • status
  • current_period_end
  • cancel_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:

  • active
  • trialing
  • past_due
  • canceled
  • unpaid
  • incomplete
frontend
backend checkout endpoint
Stripe Checkout
webhook endpoint
database
feature gating

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:

python
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:

bash
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:

sql
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.

python
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.

python
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.

python
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.

python
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.

python
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.

python
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 flow

11) Map Stripe status to access

Define this once and use it in your backend authorization.

python
ALLOWED_ACCESS_STATUSES = {"active", "trialing"}

def has_paid_access(subscription_status: str) -> bool:
    return subscription_status in ALLOWED_ACCESS_STATUSES

Example check:

python
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.

python
@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.

bash
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_id and stripe_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.updated or customer.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:

  1. Confirm the Checkout Session is created with the expected key and recurring price ID.
  2. Confirm Stripe delivers events to your webhook endpoint.
  3. Confirm signature verification passes.
  4. Confirm your database writes succeed.
  5. Confirm your backend access check reads the latest subscription state.

Useful commands:

bash
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.log

Specific 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.
Browser
App
Stripe
DB
Start Checkout
Create Session
Redirect to Checkout
Webhook (invoice.paid)
Update Subscription

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_id and stripe_subscription_id locally.
  • 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.completed
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.paid
  • invoice.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.