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
| Parameter | Where | Default | Effect |
|---|---|---|---|
mode | Checkout Session | none (required) | "subscription" for recurring billing; "payment" for a one-off charge. |
unit_amount | Price | none (required) | Price in the smallest currency unit (cents). 2000 means $20.00. |
client_reference_id | Checkout Session | none | Your own user ID, returned in the webhook so you know who paid. |
STRIPE_WEBHOOK_SECRET | .env | none (required) | The whsec_ secret used to verify each event is genuinely from Stripe. |
Troubleshooting
stripe.SignatureVerificationError— You parsed the body before verifying, or used the wrong signing secret. Pass the raw bytes fromawait request.body(), and confirmSTRIPE_WEBHOOK_SECRETmatches the one yourstripe listensession or Dashboard endpoint shows.checkout.session.completednever arrives — Your webhook is not reachable. With the Stripe CLI, keepstripe listen --forward-to ...running in a second terminal; in production, register the public URL under Developers → Webhooks in the Dashboard.stripe.error.InvalidRequestError: No such price—STRIPE_PRICE_IDis wrong or from live mode while your key is test mode. Re-runcreate_price.pyin the same mode as your secret key and copy the fresh ID.- User charged but still on the free plan — The webhook arrived but
client_reference_iddid not match any user. Confirm Step 2 setsclient_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_idlink 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_customerID 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.