Stripe Integration for SaaS Billing
Billing is the lifeblood of SaaS. Stripe is the payment processor used by 70% of SaaS startups, handling subscriptions, invoices, tax compliance, and payouts. This guide integrates Stripe into your FastAPI backend: creating customers, managing subscriptions, processing webhooks securely, and providing a customer billing portal. You'll handle recurring charges, plan upgrades/downgrades, and tax calculations without building a payment processor from scratch.
Why Stripe Over Rolling Your Own
Building a payment system means handling PCI-DSS compliance (payment card industry security), PCI secrets management, and dozens of edge cases (failed charges, chargebacks, tax liability). Stripe abstracts this: they store card data, handle compliance, manage failed charge retries, and provide an audit trail. You integrate via their API and focus on business logic. Stripe charges 2.9% + $0.30 per successful charge for SaaS; the saved engineering time pays for itself in weeks.
Setting Up Stripe
Install the Stripe Python library:
pip install stripe
Get your API keys from the Stripe Dashboard. Store them as environment variables:
# .env
STRIPE_SECRET_KEY=sk_live_your_live_key
STRIPE_PUBLISHABLE_KEY=pk_live_your_publishable_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
Initialize Stripe in your app:
import stripe
import os
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
STRIPE_PUBLISHABLE_KEY = os.getenv("STRIPE_PUBLISHABLE_KEY")
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")
Creating Stripe Customers
Map your tenants to Stripe customers:
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
class Subscription(BaseModel):
price_id: str
@app.post("/billing/create-customer")
async def create_customer(
session: Annotated[Session, Depends(get_db)],
current_user: Annotated[TokenData, Depends(get_current_user)]
):
"""
Create a Stripe customer for the tenant.
Called once during tenant onboarding.
"""
tenant = session.query(Tenant).filter(
Tenant.id == current_user.tenant_id
).first()
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
# Check if already a Stripe customer
if tenant.stripe_customer_id:
return {
"stripe_customer_id": tenant.stripe_customer_id,
"message": "Customer already exists"
}
# Create in Stripe
customer = stripe.Customer.create(
email=f"billing@{tenant.slug}.example.com",
name=tenant.name,
metadata={"tenant_id": str(current_user.tenant_id)}
)
# Store the Stripe ID
tenant.stripe_customer_id = customer.id
session.commit()
return {
"stripe_customer_id": customer.id,
"message": "Customer created successfully"
}
Managing Subscriptions
Create a subscription when a tenant upgrades:
@app.post("/billing/subscribe")
async def create_subscription(
request: Subscription,
session: Annotated[Session, Depends(get_db)],
current_user: Annotated[TokenData, Depends(get_current_user)]
):
"""
Subscribe a tenant to a plan.
Creates a recurring charge that will renew monthly.
"""
tenant = session.query(Tenant).filter(
Tenant.id == current_user.tenant_id
).first()
if not tenant or not tenant.stripe_customer_id:
raise HTTPException(
status_code=400,
detail="Tenant must create a Stripe customer first"
)
# Create subscription
subscription = stripe.Subscription.create(
customer=tenant.stripe_customer_id,
items=[{"price": request.price_id}],
payment_behavior="default_incomplete",
expand=["latest_invoice.payment_intent"]
)
# Store subscription ID
tenant.stripe_subscription_id = subscription.id
tenant.subscription_status = subscription.status # "incomplete", "active", etc.
session.commit()
return {
"subscription_id": subscription.id,
"status": subscription.status,
"client_secret": subscription.latest_invoice.payment_intent.client_secret
}
The client_secret is passed to the frontend's Stripe.js to complete payment via 3D Secure (SCA) if required.
Handling Subscription Changes
Upgrade or downgrade a subscription:
@app.post("/billing/change-plan")
async def change_subscription_plan(
new_price_id: str,
session: Annotated[Session, Depends(get_db)],
current_user: Annotated[TokenData, Depends(get_current_user)]
):
"""
Change the subscription plan.
Prorates charges: if upgrading mid-cycle, charges the difference; if downgrading, credits.
"""
tenant = session.query(Tenant).filter(
Tenant.id == current_user.tenant_id
).first()
if not tenant or not tenant.stripe_subscription_id:
raise HTTPException(status_code=400, detail="No active subscription")
# Get current subscription
subscription = stripe.Subscription.retrieve(tenant.stripe_subscription_id)
# Update the price
updated_subscription = stripe.Subscription.modify(
tenant.stripe_subscription_id,
items=[{
"id": subscription.items.data[0].id,
"price": new_price_id
}],
proration_behavior="create_prorations" # Prorate charges
)
tenant.subscription_status = updated_subscription.status
session.commit()
return {
"subscription_id": updated_subscription.id,
"status": updated_subscription.status,
"new_amount": updated_subscription.items.data[0].price.unit_amount / 100 # In dollars
}
Processing Webhooks Securely
Stripe sends webhooks for events: payment succeeded, subscription renewed, failed charge, etc. Webhooks are critical for billing accuracy (don't trust client-side assertions about payment):
from fastapi import Request, HTTPException
import hmac
import hashlib
@app.post("/billing/webhooks/stripe")
async def stripe_webhook(
request: Request,
session: Annotated[Session, Depends(get_db)]
):
"""
Webhook endpoint for Stripe events.
Verifies the signature to ensure the request is authentic.
"""
# Read raw body (required for signature verification)
body = await request.body()
signature = request.headers.get("stripe-signature")
# Verify signature
try:
event = stripe.Webhook.construct_event(
body,
signature,
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 specific events
if event["type"] == "customer.subscription.updated":
handle_subscription_updated(event["data"]["object"], session)
elif event["type"] == "customer.subscription.deleted":
handle_subscription_deleted(event["data"]["object"], session)
elif event["type"] == "invoice.paid":
handle_invoice_paid(event["data"]["object"], session)
elif event["type"] == "invoice.payment_failed":
handle_invoice_payment_failed(event["data"]["object"], session)
return {"received": True}
def handle_subscription_updated(subscription_data: dict, session: Session):
"""Handle subscription updates."""
tenant = session.query(Tenant).filter(
Tenant.stripe_subscription_id == subscription_data["id"]
).first()
if tenant:
tenant.subscription_status = subscription_data["status"]
tenant.subscription_current_period_end = datetime.fromtimestamp(
subscription_data["current_period_end"]
)
session.commit()
print(f"Updated subscription for tenant {tenant.id}: {subscription_data['status']}")
def handle_invoice_paid(invoice_data: dict, session: Session):
"""Log successful payment."""
print(f"Invoice {invoice_data['id']} paid: ${invoice_data['total'] / 100}")
# Log to analytics, send receipt email, etc.
def handle_invoice_payment_failed(invoice_data: dict, session: Session):
"""Handle failed payment (dunning)."""
tenant = session.query(Tenant).filter(
Tenant.stripe_customer_id == invoice_data["customer"]
).first()
if tenant:
# Send email to tenant: "Your payment failed. Update your card."
# Stripe retries automatically; you can also implement custom dunning logic
print(f"Payment failed for tenant {tenant.id}")
Billing Portal
Let tenants manage their subscription in Stripe's hosted portal:
@app.post("/billing/portal")
async def create_billing_portal_session(
session: Annotated[Session, Depends(get_db)],
current_user: Annotated[TokenData, Depends(get_current_user)]
):
"""
Create a session for Stripe's billing portal.
User redirects to the returned URL to manage payment method, view invoices, etc.
"""
tenant = session.query(Tenant).filter(
Tenant.id == current_user.tenant_id
).first()
if not tenant or not tenant.stripe_customer_id:
raise HTTPException(status_code=400, detail="No Stripe customer")
portal_session = stripe.BillingPortal.Session.create(
customer=tenant.stripe_customer_id,
return_url="https://yourapp.com/settings/billing"
)
return {"url": portal_session.url}
The user visits this URL to:
- Update payment method
- Download invoices
- Cancel subscription
- Change billing email
Tax Compliance
Stripe Tax calculates sales tax automatically. When creating a subscription, pass the customer's tax ID:
@app.post("/billing/subscribe-with-tax")
async def create_subscription_with_tax(
request: Subscription,
session: Annotated[Session, Depends(get_db)],
current_user: Annotated[TokenData, Depends(get_current_user)]
):
"""
Create subscription with automatic tax calculation.
Stripe Tax determines applicable rates by customer location.
"""
tenant = session.query(Tenant).filter(
Tenant.id == current_user.tenant_id
).first()
# Enable automatic tax
subscription = stripe.Subscription.create(
customer=tenant.stripe_customer_id,
items=[{"price": request.price_id}],
automatic_tax={"enabled": True},
billing_cycle_anchor=int(datetime.utcnow().timestamp())
)
return {"subscription_id": subscription.id}
Stripe calculates and collects tax automatically; you remit to authorities via Stripe Tax Dashboard.
Key Takeaways
- Create a Stripe customer for each tenant during onboarding; store the customer ID in your database.
- Subscriptions are created with a price ID; Stripe handles recurring charges automatically.
- Webhooks are the source of truth for billing state (payment success, renewal, failure). Verify signatures.
- Offer a billing portal (Stripe-hosted) for tenants to manage payment methods and view invoices.
- Enable Stripe Tax for automatic calculation; Stripe remits to authorities on your behalf.
Frequently Asked Questions
What if a customer's card is declined?
Stripe retries automatically (up to 3–5 times with backoff). If all retries fail, Stripe sends a invoice.payment_failed webhook. You can implement dunning (email → payment retry → grace period → cancel) in the webhook handler.
How do I offer a free trial?
Create a plan with a trial_period_days parameter:
stripe.Subscription.create(
customer=stripe_customer_id,
items=[{"price": price_id}],
trial_period_days=14
)
During trial, subscription.status = "trialing". After trial, Stripe charges automatically.
Can I prorate when a customer cancels mid-cycle?
Yes. Stripe handles proration automatically. Use stripe.Subscription.delete(subscription_id, prorate=True) to issue a final credit.
How do I handle refunds?
Use stripe.Refund.create(charge=charge_id, amount=1000) (amount in cents). Stripe can refund within 180 days. For subscriptions, refund the last invoice: stripe.Invoice.retrieve(invoice_id).paid → refund if paid.
Should I store sensitive payment data?
Never. Stripe tokenizes cards; store the token ID, not the card number. Stripe.js handles card collection on the frontend. Your backend never sees raw card data.