8 min read

FastAPI Stripe Payment Gateway Integration - Tutorial with Examples (2025)

Learn FastAPI payment gateway integration with Stripe in this complete tutorial. Step-by-step examples for payment integration covering one-time payments, subscriptions, webhooks, and production deployment.

100% Human Written Content

Introduction

Building a SaaS with FastAPI? You need a payment gateway. This FastAPI payment gateway integration tutorial shows you how to integrate Stripe payments into your Python FastAPI application with complete code examples.

What you'll learn in this payment integration guide:

  • One-time payment checkout
  • Subscription billing setup
  • Webhook handling for payment events
  • Production deployment checklist

Style: My style in this tutorial is minimalistic and direct. I focus on essential concepts and practical code examples. If you are integrating Stripe with FastAPI in an existing application, you can adapt the code snippets as needed.

Github: All code examples from this tutorial can be found in our GitHub repository.

Prerequisites: Basic Python and FastAPI knowledge, Stripe account

Why Stripe + FastAPI?

Stripe is the industry standard payment gateway for SaaS applications. Used by Shopify, GitHub, Slack. Handles complex billing, supports 135+ currencies, and has excellent documentation.

Why this payment integration works perfectly with FastAPI:

  • Both have modern, clean APIs
  • Stripe's Python SDK supports async operations
  • Fast and reliable payment processing

Initial Setup

Install dependencies:

pip install fastapi uvicorn stripe pydantic-settings

Create .env file:

STRIPE_SECRET_KEY=sk_test_your_key_here
STRIPE_PUBLISHABLE_KEY=pk_test_your_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
DOMAIN=http://localhost:8000

Get your keys from Stripe Dashboard.

Note on DOMAIN: In production, set it to your actual domain like https://yourdomain.com.

Basic FastAPI setup:

# main.py
from fastapi import FastAPI
from pydantic_settings import BaseSettings, SettingsConfigDict
import stripe

class Settings(BaseSettings):
    stripe_secret_key: str
    stripe_publishable_key: str
    stripe_webhook_secret: str
    domain: str = "http://localhost:8000"  # Default for development

    model_config = SettingsConfigDict(env_file=".env")

settings = Settings()

app = FastAPI()
stripe.api_key = settings.stripe_secret_key

@app.get("/")
def read_root():
    return {"message": "Stripe + FastAPI"}

Run it:

uvicorn main:app --reload

One-Time Payments

Stripe Checkout is the easiest way to accept payments in your Python application. It handles the entire payment UI and checkout flow securely.

Create Checkout Session

from fastapi import HTTPException
from pydantic import BaseModel
import stripe

# Define products server-side (never trust client amounts!)
PRODUCTS = {
    "ebook": {"name": "Python SaaS eBook", "amount": 2900},  # $29.00
    "course": {"name": "FastAPI Masterclass", "amount": 9900},  # $99.00
    "template": {"name": "SaaS Template", "amount": 7900},  # $79.00
}

class CheckoutRequest(BaseModel):
    product_id: str


@app.post("/create-checkout-session")
async def create_checkout_session(request: CheckoutRequest):
    # Validate product exists
    if request.product_id not in PRODUCTS:
        raise HTTPException(status_code=400, detail="Invalid product")

    product = PRODUCTS[request.product_id]

    try:
        checkout_session = await stripe.checkout.Session.create_async(
            payment_method_types=["card"],
            line_items=[{
                "price_data": {
                    "currency": "usd",
                    "product_data": {
                        "name": product["name"],
                    },
                    "unit_amount": product["amount"],
                },
                "quantity": 1,
            }],
            mode="payment",
            success_url=f"{settings.domain}/success?session_id={{CHECKOUT_SESSION_ID}}",
            cancel_url=f"{settings.domain}/cancel",
        )
        return {"checkout_url": checkout_session.url}
    except Exception as e:
        raise HTTPException(status_code=400, detail=str(e))

What's happening:

  • PRODUCTS dict defines prices server-side (never trust client!)
  • Client sends product_id, server looks up the price
  • payment_method_types: Accept credit cards
  • line_items: Products being purchased
  • mode='payment': One-time payment (vs subscription)
  • success_url: Where to redirect after payment.
  • The endpoint returns the URL to redirect the user to Stripe Checkout

Frontend Integration

Simple HTML to test:

<!DOCTYPE html>
<html>
<head>
    <title>Buy Product</title>
</head>
<body>
    <h1>Buy Our Product</h1>
    <button onclick="buyProduct('ebook')">Buy eBook - $29</button>
    <button onclick="buyProduct('course')">Buy Course - $99</button>
    <button onclick="buyProduct('template')">Buy Template - $79</button>

    <script>
        async function buyProduct(productId) {
            const response = await fetch('/create-checkout-session', {
                method: 'POST',
                headers: {'Content-Type': 'application/json'},
                body: JSON.stringify({
                    product_id: productId
                })
            });
            const {checkout_url} = await response.json();
            window.location.href = checkout_url;
        }
    </script>
</body>
</html>

Test It

Use Stripe test cards:

Scenario Card Number
Success 4242 4242 4242 4242
Decline 4000 0000 0000 0002
Requires authentication 4000 0025 0000 3155

Use any future expiry date (e.g., 12/34) and any 3-digit CVC.

Success and Cancel Pages

Add your success and cancel pages to handle post-payment flow:

@app.get("/success")
async def payment_success(session_id: str):
    try:
        session = await stripe.checkout.Session.retrieve_async(session_id)

        if session.payment_status == "paid":
            # TODO: Here, you could save the order to your database
            # TODO: Here, you could send a confirmation email to the user
            return {"status": "success", "customer_email": session.customer_details.email}

        return {"status": "pending"}
    except Exception as e:
        raise HTTPException(status_code=400, detail=str(e))

@app.get("/cancel")
async def payment_cancel():
    return {"message": "Payment cancelled"}

Subscription Billing

Subscriptions are different from one-time payments. For recurring billing, you need to create Products and Prices in Stripe first.

Create Products & Prices

You can do this in the Stripe Dashboard or via this one-time setup script:

# setup_stripe_products.py - Run this once to create your products
import stripe
import os
from dotenv import load_dotenv

load_dotenv()
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")

# Basic plan
basic_product = stripe.Product.create(name="Basic Plan")
basic_price = stripe.Price.create(
    product=basic_product.id,
    unit_amount=999,  # $9.99
    currency="usd",
    recurring={"interval": "month"}
)

# Pro plan
pro_product = stripe.Product.create(name="Pro Plan")
pro_price = stripe.Price.create(
    product=pro_product.id,
    unit_amount=2999,  # $29.99
    currency="usd",
    recurring={"interval": "month"}
)

print(f"Basic Plan Price ID: {basic_price.id}")
print(f"Pro Plan Price ID: {pro_price.id}")

Run it once:

python setup_stripe_products.py

Save these price_id values in your app config. You'll use them for subscriptions.

Subscription Checkout

class SubscriptionRequest(BaseModel):
    price_id: str  # e.g., "price_1234567890"
    customer_email: str

@app.post("/create-subscription-checkout")
async def create_subscription_checkout(request: SubscriptionRequest):
    try:
        # Create or get customer
        customers = await stripe.Customer.list_async(email=request.customer_email, limit=1)

        if customers.data:
            customer = customers.data[0]
        else:
            customer = await stripe.Customer.create_async(email=request.customer_email)

        # Create checkout session for subscription
        checkout_session = await stripe.checkout.Session.create_async(
            customer=customer.id,
            payment_method_types=["card"],
            line_items=[{
                "price": request.price_id,
                "quantity": 1,
            }],
            mode="subscription",
            success_url=f"{settings.domain}/success?session_id={{CHECKOUT_SESSION_ID}}",
            cancel_url=f"{settings.domain}/cancel",
        )

        return {"checkout_url": checkout_session.url}
    except Exception as e:
        raise HTTPException(status_code=400, detail=str(e))

Key differences from one-time payments:

  • mode='subscription' instead of mode='payment'
  • Use price_id instead of inline price data
  • Link to a Customer record

Customer Portal

The Customer Portal lets users manage their subscription. They can:

  • Update payment method
  • Cancel subscription
  • View invoices

You can add it in the form of a button or a link, that will make a request to your FastAPI backend, create a Stripe portal session, and redirect the user to the portal URL:

@app.post("/create-portal-session")
async def create_portal_session(customer_email: str):
    try:
        # Get customer
        customers = await stripe.Customer.list_async(email=customer_email, limit=1)
        if not customers.data:
            raise HTTPException(status_code=404, detail="Customer not found")

        customer = customers.data[0]

        # Create portal session
        portal_session = await stripe.billing_portal.Session.create_async(
            customer=customer.id,
            return_url=f"{settings.domain}/dashboard",
        )

        return {"portal_url": portal_session.url}
    except Exception as e:
        raise HTTPException(status_code=400, detail=str(e))

Note: This code sample is an unauthenticated endpoint for simplicity. In your app, ensure only logged-in users can access their portal.

Check Subscription Status

Protect routes that require an active subscription:

from fastapi import Depends, HTTPException, Header

async def require_active_subscription(
    # In your app, ensure customer_email value comes
    # from the authenticated user him/herself
    customer_email: str = Depends(customer_email_dependency)
):
    customers = await stripe.Customer.list_async(
        email=customer_email,
        limit=1
    )

    if not customers.data:
        raise HTTPException(
            status_code=403,
            detail="No customer found"
        )

    customer = customers.data[0]

    # Get subscriptions
    subscriptions = await stripe.Subscription.list_async(
        customer=customer.id,
        status="active",
        limit=1
    )

    if not subscriptions.data:
        raise HTTPException(
            status_code=403,
            detail="No active subscription"
        )

    return subscriptions.data[0]

@app.get("/premium-content")
async def premium_content(subscription = Depends(require_active_subscription)):
    return {
        "message": "Welcome to premium content!",
        "subscription_id": subscription.id
    }

This is a simple version, but the downside is that you make requests to Stripe on every request. For better performance, cache subscription status in your database and update it via webhooks.

Webhook Integration

What is a Stripe Webhook: When certain events happen in Stripe (payment succeeded, subscription cancelled, etc), Stripe sends an HTTP POST request to your server with event details.

Why webhooks matter:

  • User closes browser after paying (but before being redirected to your success page)
  • Subscription renewals happen automatically
  • Failed payments need handling
  • Refunds and disputes

Setup Webhook Endpoint

from fastapi import Request

@app.post("/webhook")
async def stripe_webhook(request: Request):
    payload = await request.body()
    sig_header = request.headers.get('stripe-signature')

    try:
        # Verify webhook signature. This ensures the call really comes from Stripe
        event = stripe.Webhook.construct_event(
            payload, sig_header, settings.stripe_webhook_secret
        )
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid payload")
    except stripe.error.SignatureVerificationError:
        raise HTTPException(status_code=400, detail="Invalid signature")

    # Handle different event types
    if event['type'] == 'checkout.session.completed':
        session = event['data']['object']
        handle_checkout_session(session)

    elif event['type'] == 'customer.subscription.created':
        subscription = event['data']['object']
        handle_subscription_created(subscription)

    elif event['type'] == 'customer.subscription.updated':
        subscription = event['data']['object']
        handle_subscription_updated(subscription)

    elif event['type'] == 'customer.subscription.deleted':
        subscription = event['data']['object']
        handle_subscription_deleted(subscription)

    elif event['type'] == 'invoice.payment_succeeded':
        invoice = event['data']['object']
        handle_invoice_paid(invoice)

    elif event['type'] == 'invoice.payment_failed':
        invoice = event['data']['object']
        handle_invoice_failed(invoice)

    return {"status": "success"}

def handle_checkout_session(session):
    """Called when checkout is completed"""
    customer_email = session.get('customer_details', {}).get('email')
    print(f"Payment successful for {customer_email}")
    # TODO: Update database
    # TODO: Send confirmation email
    # TODO: Grant access to product

def handle_subscription_created(subscription):
    """Called when subscription is created"""
    customer_id = subscription['customer']
    print(f"Subscription created for customer {customer_id}")
    # TODO: Update user's subscription status in database

def handle_subscription_updated(subscription):
    """Called when subscription is updated (plan change, etc)"""
    customer_id = subscription['customer']
    status = subscription['status']
    print(f"Subscription updated for {customer_id}: {status}")
    # TODO: Update database

def handle_subscription_deleted(subscription):
    """Called when subscription is cancelled"""
    customer_id = subscription['customer']
    print(f"Subscription cancelled for {customer_id}")
    # TODO: Revoke access
    # TODO: Send cancellation email

def handle_invoice_paid(invoice):
    """Called when recurring payment succeeds"""
    customer_id = invoice['customer']
    print(f"Invoice paid for {customer_id}")
    # TODO: Extend subscription
    # TODO: Send receipt

def handle_invoice_failed(invoice):
    """Called when recurring payment fails"""
    customer_id = invoice['customer']
    print(f"Payment failed for {customer_id}")
    # TODO: Send payment failed email
    # TODO: Maybe pause access after grace period

Test Webhooks Locally

Install Stripe CLI:

# Login
stripe login

# Forward webhooks to localhost
stripe listen --forward-to localhost:8000/webhook

This gives you a webhook signing secret. Add it to your .env:

STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx

Trigger test events:

stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed

Configure Webhook in Stripe Dashboard

For production, register your webhook endpoint in Stripe.

  • Go to Stripe Dashboard > Webhooks and enter your webhook url (ex: https://yourdomain.com/webhook)
  • After creating the endpoint, click on it
  • Copy the sining secret (starts with whsec_) to your .env:
STRIPE_WEBHOOK_SECRET=whsec_your_production_secret_here

Important: Each webhook endpoint has its own signing secret. Development (Stripe CLI) and production (Dashboard) use different secrets.

Testing

Test Card Numbers

You already saw the basic test cards earlier. Here are additional scenarios:

Scenario Card Number
Success 4242 4242 4242 4242
Decline 4000 0000 0000 0002
Requires authentication 4000 0025 0000 3155
Insufficient funds 4000 0000 0000 9995
Expired card 4000 0000 0000 0069
Processing error 4000 0000 0000 0119

Use any future expiry date (e.g., 12/34) and any 3-digit CVC.

See the full list in Stripe's testing docs.

Production Deployment Checklist

Before going live:

  • Switch to live API keys - Get from Stripe Dashboard (not test mode)
  • Set up live webhook endpoint - Register on Stripe Dashboard
  • Store keys securely - Use environment variables, not hardcoded
  • Update URLs - Change localhost URLs to your domain
  • Set up monitoring - Track failed payments, webhook errors
  • Test end-to-end - Use a real card in test mode, then in live mode with $1 test
  • Review Stripe Dashboard - Check for any configuration warnings

Security Best Practices

  1. Never expose secret keys - Only publishable keys go to frontend
  2. Always verify webhook signatures - Prevents fake webhooks
  3. Use HTTPS - Required by Stripe, protects data in transit
  4. Validate amounts server-side - Never trust client-submitted prices
  5. Store minimal payment data - Let Stripe handle sensitive data (PCI compliance)

Critical: Server-side price validation

All examples in this guide follow this pattern, but it's important enough to emphasize:

Bad example:

# ❌ DON'T DO THIS
@app.post("/checkout")
async def bad_checkout(amount: int):  # Client controls amount!
    session = await stripe.checkout.Session.create_async(
        line_items=[{"price_data": {"unit_amount": amount}}]  # Dangerous
    )

Good example:

# ✅ DO THIS
PRODUCTS = {
    "basic": {"amount": 999, "name": "Basic Plan"},
    "pro": {"amount": 2999, "name": "Pro Plan"},
}

@app.post("/checkout")
async def good_checkout(product_id: str):
    if product_id not in PRODUCTS:
        raise HTTPException(status_code=400, detail="Invalid product")

    product = PRODUCTS[product_id]  # Server controls amount
    session = await stripe.checkout.Session.create_async(
        line_items=[{"price_data": {"unit_amount": product["amount"]}}]
    )

Advanced Topics

These topics are beyond this guide, but you may encounter them as your app grows.

Payment Intents API: More control than Checkout. Build custom payment flows.

Multi-currency: Support multiple currencies with dynamic pricing.

Metered billing: Charge based on usage (API calls, storage, etc).

stripe.SubscriptionItem.create_usage_record(
    subscription_item_id,
    quantity=100,  # 100 API calls
    timestamp=int(time.time())
)

Proration: Automatically calculate credits when upgrading/downgrading.

Coupons: Apply discounts.

stripe.checkout.Session.create(
    ...,
    discounts=[{"coupon": "SUMMER20"}]
)

Use idempotency keys to prevent duplicate charges:

import uuid

idempotency_key = str(uuid.uuid4())

checkout_session = await stripe.checkout.Session.create_async(
    ...,
    idempotency_key=idempotency_key
)

If the request fails and you retry, Stripe returns the original result instead of creating a duplicate.

Stripe Tax: Automatic tax calculation for global sales.

See Stripe Docs for more details.

Common errors

Problem: "No such customer"

Solution: Make sure you're using the same API keys (test vs live). Customers created in test mode don't exist in live mode.

Problem: Webhook signature verification fails

Solution:

  • Check you're using the raw request body (not parsed JSON)
  • Verify STRIPE_WEBHOOK_SECRET matches Stripe CLI or Dashboard
  • Make sure you're reading the stripe-signature header correctly

Problem: Checkout session not creating

Solution:

  • Verify API keys are correct
  • Check line_items format (must be array of objects)
  • Ensure amounts are integers in cents (not decimals)
  • Look at error message: Stripe errors are descriptive

Problem: Subscriptions not showing up

Solution:

  • Verify you're using the same customer_id
  • Check subscription status (might be incomplete if payment failed)
  • Look in Stripe Dashboard under Customers

Problem: Production payments failing but test works

Solution:

  • Did you switch to live API keys?
  • Is webhook endpoint using HTTPS?
  • Are success/cancel URLs correct?
  • Check Stripe Dashboard logs

Next Steps

You now have a complete Stripe payment integration in your FastAPI application. What's next?

Enhance your payment integration:

  • Send email confirmations after successful payments (SendGrid, Mailgun)
  • Build a user dashboard to show subscription status
  • Add team billing (multiple users per subscription)
  • Implement usage-based pricing
  • Add analytics to track revenue

Improve user experience:

  • Show payment history
  • Let users download invoices
  • Add upgrade/downgrade flows
  • Implement trial periods

Production hardening:

  • Add comprehensive logging
  • Set up error monitoring (Sentry)
  • Implement retry logic for failed webhooks
  • Add rate limiting

Skip the Setup

Building all this from scratch takes time. This is why I've packed all of my experience into FastSaaS, my FastAPI SaaS Boilerplate. It includes:

  • ✅ Complete Stripe integration
  • ✅ Webhook handling with database updates
  • ✅ Customer portal integration
  • ✅ User authentication
  • ✅ Team billing
  • ✅ Admin dashboard
  • ✅ Production-ready deployment

Questions? Email me at salim@fast-saas.com, I'll be happy to help!

In all cases, all code snippets from this article are freely available in our GitHub repository.

Salim Aboubacar

Written by Salim Aboubacar

Building FastSaaS to help developers ship faster.

Ready to build your SaaS?

Get started with FastSaaS and ship your product in days, not months.

Get FastSaaS