Business Apps

Add Stripe Billing to an AI SaaS with Python

Add Stripe subscriptions to a FastAPI AI app: create products and prices, open a Checkout Session, and unlock access from the webhook. Runnable 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. If you only have a script and no HTTP endpoints yet, Turn a Python AI Script into an API with FastAPI gets you to the starting line in one sitting. 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:

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

A file on disk is the right answer while you build. The day you put this on a server, move the same two values into your host's secret storage instead, which Manage API Keys Safely in Production walks through end to end. A leaked Stripe secret key is worse than a leaked model key: it can move real money.

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.

Holding this shape in your head prevents most Stripe confusion later. The product is a durable name you rarely change, prices are the numbers hanging off it, and a payment page always points at exactly one price ID.

How a Stripe product, its prices and a Checkout Session fit together One product sits at the top, a monthly price and an optional yearly price hang beneath it, and a Checkout Session at the bottom charges exactly one of those price IDs. Product: Pro Plan what you sell Monthly price interval: month price_1AbC... Yearly price interval: year optional second Checkout Session charges one price ID
A product is the name you sell under, each price is one amount and billing interval attached to it, and every Checkout Session charges exactly one of those price IDs.

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 — the endpoint that starts a paid subscription."""
import os

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

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

from auth import current_user  # your auth layer: 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 — receives, verifies and applies Stripe events."""
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.

Put Steps 2 and 3 together and you have a round trip that leaves your server twice and comes back twice. The diagram below traces it, and it is worth noticing that the only arrow that changes a user's plan is the last one.

The round trip from a checkout click to an unlocked account Your app creates a Checkout Session, Stripe shows a hosted card page, the payment succeeds, and Stripe posts a signed webhook back to your server which verifies it and sets the plan to pro. Your FastAPI app Stripe Create the session POST /billing/checkout Stripe hosted page card never hits you Payment succeeds sends webhook event Verify signature then set plan to pro
Nothing in the first three boxes is proof of payment; only the signed webhook arriving back at your server is safe to act on.

Step 4: Downgrade when a subscription ends

A subscription is not one event, it is a relationship. Cards expire, customers cancel, and renewals fail, and each of those arrives at the same endpoint as a different event["type"]. Two are worth handling on day one: customer.subscription.deleted, which means the subscription has actually ended, and invoice.payment_failed, which means a renewal charge did not go through. Route them through one small function so the plan logic lives in a single place.

from auth import USERS  # same user table as Step 3

CANCEL_EVENTS = {"customer.subscription.deleted", "invoice.payment_failed"}


def apply_event(event: dict) -> None:
    """Turn one verified Stripe event into a plan change."""
    obj = event["data"]["object"]
    if event["type"] == "checkout.session.completed":
        for user in USERS.values():
            if user["id"] == obj["client_reference_id"]:
                user["plan"] = "pro"
                user["stripe_customer"] = obj["customer"]
    elif event["type"] in CANCEL_EVENTS:
        for user in USERS.values():
            if user.get("stripe_customer") == obj["customer"]:
                user["plan"] = "free"

Call apply_event(event) from the webhook in Step 3 right after construct_event returns, and delete the inline if block it replaces. Notice that every branch only sets a field rather than incrementing anything: Stripe will occasionally deliver the same event twice, so a handler that can run twice without harm saves you from double-charging your own logic. Because these events fire days or weeks after the sale, keep a record of what arrived and when — Log and Monitor AI API Calls in Production shows the same logging pattern you can point at billing events.

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.

Read across the three of them and the trade-off is easy to state: only Checkout hands your user ID back to you, only Checkout and Payment Links can take a first payment, and the Portal is the cheapest thing on the list to add because Stripe builds the whole page.

Checkout Session, Payment Links and Billing Portal compared A three-column matrix comparing the Stripe Checkout Session, Payment Links and Billing Portal on whether they carry your user ID, whether they can take the first payment, and how much setup each needs. Checkout Session Payment Links Billing Portal Carries your user ID Yes client_reference_id No map it yourself Yes for existing users Takes first payment Yes subscription mode Yes paste a URL anywhere No manages plans only Setup effort One endpoint plus a webhook None made in Dashboard One endpoint no page to build
Checkout is the only option that ties a payment to one of your accounts automatically, which is why it is the default for an AI SaaS with logins.

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. It also pays to know what a subscriber actually costs you before you set the next price: Estimate OpenAI API Costs with Python turns token counts into a monthly figure you can compare against the $20 you just charged.

Back to SaaS MVP with Python and AI.

Frequently asked questions

Do I need to handle credit card numbers to bill with Stripe?

No. Stripe Checkout hosts the card form on Stripe's own pages, so card numbers never touch your server. You redirect the user to Stripe, they pay, and Stripe sends your server an event saying it succeeded. This keeps you out of most PCI compliance scope.

Why must I activate access from a webhook instead of the success page?

The success page only proves the user reached it, not that the payment cleared, and users can close the tab before redirecting. The webhook is Stripe telling your server directly that money moved, so it is the only event you can trust to unlock paid features.

What is the difference between a product and a price in Stripe?

A product is the thing you sell, like 'Pro Plan'. A price is how much it costs and how often, like '$20 per month'. One product can have several prices, such as a monthly and a yearly option, and Checkout always charges a specific price.

How do I test Stripe billing without real money?

Use your test-mode API keys and the test card number 4242 4242 4242 4242 with any future expiry and any CVC. Stripe processes it like a real payment but charges nothing, and the Stripe CLI can forward webhook events to your local server.

How do I let customers cancel or update their own subscription?

Use the Stripe Billing Portal. You create a portal session for a logged-in customer and redirect them to it, and Stripe gives them a hosted page to change plans, update cards, or cancel without any extra code from you.