Business Apps

Add Stripe Billing to an AI SaaS with Python

This guide shows you how to charge users a monthly subscription for your AI app with Stripe in under 30 minutes, without ever handling a card number yourself. You will create a product and a price, send users to a hosted payment page, and unlock paid access the moment Stripe confirms the money moved.

It builds directly on the service from SaaS MVP with Python and AI, the main guide for this section, where each user already has a record with a plan. Billing is the piece that turns that "plan": "free" field into "plan": "pro" after a real payment.

Prerequisites

You need the FastAPI service from the main guide running, Python 3.10 or newer, and a free Stripe account. From the Stripe Dashboard, switch to Test mode (the toggle in the top corner) and copy your secret key from Developers → API keys. Install the one new package this guide adds:

pip install "stripe>=9.0"

Stripe gives you two secrets. The secret key (starts with sk_test_) authenticates your API calls. The webhook signing secret (starts with whsec_) proves that incoming events really came from Stripe. Store both in .env, never in your code, because anything in your code can leak into Git history:

# .env
STRIPE_SECRET_KEY=sk_test_your_real_key_here
STRIPE_WEBHOOK_SECRET=whsec_filled_in_during_step_4

Add .env to your .gitignore immediately so your keys are never committed:

echo ".env" >> .gitignore

If your service does not yet authenticate callers, set that up first with Add User Authentication to a Python AI App, because billing only makes sense once you can tell users apart.

Step 1: Create a product and a recurring price

Stripe separates what you sell (a product) from what it costs (a price). You create these once, not on every signup, so run the snippet below as a throwaway script rather than wiring it into your app. It makes a "Pro Plan" product and a $20-per-month price, then prints the price ID you will need next.

# create_price.py — run this once, then delete it
import os
from pathlib import Path

import stripe

for line in (Path(__file__).parent / ".env").read_text().splitlines():
    if line and not line.startswith("#") and "=" in line:
        k, v = line.split("=", 1)
        os.environ.setdefault(k.strip(), v.strip())

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]

product = stripe.Product.create(name="Pro Plan")
price = stripe.Price.create(
    product=product.id,
    unit_amount=2000,            # amount in cents: $20.00
    currency="usd",
    recurring={"interval": "month"},
)
print("Price ID:", price.id)     # looks like price_1AbC...

Run python create_price.py. Copy the printed Price ID into your .env as STRIPE_PRICE_ID, because Checkout charges a specific price, not a product. You can also create products and prices by hand in the Dashboard under Product catalog; the API just makes it repeatable.

Step 2: Open a Checkout Session

A Checkout Session is a single hosted payment page that Stripe builds for one customer. You tell Stripe which price to charge and where to send the user afterward, Stripe returns a URL, and you redirect the user there. The card form lives entirely on Stripe's pages, so card numbers never reach your server.

The key detail is client_reference_id: it carries your own user's ID through Stripe and comes back in the webhook, which is how you later know which of your users paid.

# billing.py
import os

import stripe
from fastapi import APIRouter, Depends
from fastapi.responses import RedirectResponse

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
router = APIRouter()

# current_user comes from your auth layer (see the Authentication guide).
from auth import current_user  # returns {"id": "...", "plan": "...", "email": "..."}


@router.post("/billing/checkout")
def start_checkout(user: dict = Depends(current_user)) -> RedirectResponse:
    session = stripe.checkout.Session.create(
        mode="subscription",                       # recurring, not one-off
        line_items=[{"price": os.environ["STRIPE_PRICE_ID"], "quantity": 1}],
        client_reference_id=user["id"],            # ties the payment to your user
        customer_email=user["email"],              # pre-fills the email field
        success_url="http://127.0.0.1:8000/billing/success?session_id={CHECKOUT_SESSION_ID}",
        cancel_url="http://127.0.0.1:8000/billing/cancel",
    )
    return RedirectResponse(session.url, status_code=303)

Hitting this endpoint sends the user to Stripe's page. The {CHECKOUT_SESSION_ID} placeholder is filled in by Stripe, so your success page can look the session up if needed. Note that reaching success_url does not prove payment cleared, which is why the next step matters.

Step 3: Activate access from the webhook

The webhook is the only event you can trust to unlock paid features. Stripe sends an HTTP POST to an endpoint you control whenever something happens, and you react to checkout.session.completed, which fires once a subscription payment succeeds. Before trusting the payload, you verify its signature with your whsec_ secret, so an attacker cannot forge a "you got paid" event.

# webhook.py
import os

import stripe
from fastapi import APIRouter, Request, HTTPException

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"]
router = APIRouter()

from auth import USERS  # your user table: {user_id: {...}}


@router.post("/billing/webhook")
async def stripe_webhook(request: Request) -> dict:
    payload = await request.body()                 # raw bytes, do not parse first
    signature = request.headers.get("stripe-signature", "")
    try:
        event = stripe.Webhook.construct_event(payload, signature, WEBHOOK_SECRET)
    except (ValueError, stripe.SignatureVerificationError):
        raise HTTPException(status_code=400, detail="Invalid signature")

    if event["type"] == "checkout.session.completed":
        session = event["data"]["object"]
        user_id = session["client_reference_id"]   # the id you sent in Step 2
        for user in USERS.values():
            if user["id"] == user_id:
                user["plan"] = "pro"                # unlock paid access
                user["stripe_customer"] = session["customer"]
    return {"received": True}                       # 200 tells Stripe to stop retrying

Always pass the raw request body to construct_event; if you let FastAPI parse the JSON first, the signature will not match and every event fails. Return a 200 quickly: Stripe retries any webhook that does not get a fast success, so do the heavy work after responding if it is slow.

To test locally, install the Stripe CLI and run stripe listen --forward-to 127.0.0.1:8000/billing/webhook. It prints a whsec_ secret for the session; paste that into your .env, then run stripe trigger checkout.session.completed to fire a fake event and watch your user flip to pro.

Parameter quick reference

ParameterWhereDefaultEffect
modeCheckout Sessionnone (required)"subscription" for recurring billing; "payment" for a one-off charge.
unit_amountPricenone (required)Price in the smallest currency unit (cents). 2000 means $20.00.
client_reference_idCheckout SessionnoneYour own user ID, returned in the webhook so you know who paid.
STRIPE_WEBHOOK_SECRET.envnone (required)The whsec_ secret used to verify each event is genuinely from Stripe.

Troubleshooting

  1. stripe.SignatureVerificationError — You parsed the body before verifying, or used the wrong signing secret. Pass the raw bytes from await request.body(), and confirm STRIPE_WEBHOOK_SECRET matches the one your stripe listen session or Dashboard endpoint shows.
  2. checkout.session.completed never arrives — Your webhook is not reachable. With the Stripe CLI, keep stripe listen --forward-to ... running in a second terminal; in production, register the public URL under Developers → Webhooks in the Dashboard.
  3. stripe.error.InvalidRequestError: No such priceSTRIPE_PRICE_ID is wrong or from live mode while your key is test mode. Re-run create_price.py in the same mode as your secret key and copy the fresh ID.
  4. User charged but still on the free plan — The webhook arrived but client_reference_id did not match any user. Confirm Step 2 sets client_reference_id=user["id"] and that the webhook compares against that same ID.

When to use this vs. alternatives

  • Checkout Session (this guide) — Best when access is tied to a logged-in user and you want full control over when and how the redirect happens. The client_reference_id link makes it the right choice for unlocking features per account in an AI SaaS.
  • Payment Links — A no-code URL you create in the Dashboard and paste anywhere. Great for a quick paywall or a launch before you have auth, but it does not carry your user ID automatically, so mapping a payment back to an account is harder. Reach for it when speed beats integration.
  • Billing Portal — Not a way to take the first payment, but the hosted page where existing customers upgrade, change cards, or cancel. Add it after this guide so you do not have to build subscription management yourself; create a portal session for the saved stripe_customer ID and redirect.

Once billing works, protect your margin so a paying user cannot run up unlimited model cost with Rate-Limit AI API Calls in a SaaS with Python.

Back to SaaS MVP with Python and AI.