Skip to main content
ship before you scale

Payments: Stripe Billing, Webhook Handling, and the Freemium Logic That Does Not Lie to Your Database

9 min read Chapter 22 of 42

Payments

Stripe handles the complexity of payments. Your job is narrower than you think and harder than it looks. Stripe processes the credit card. Stripe manages the subscription lifecycle. Stripe sends invoices, retries failed payments, and handles proration when a customer changes plans. The SaaS developer’s responsibility is threefold: create the right Stripe objects when users take actions, handle the webhook events that Stripe sends when billing state changes, and keep the local database consistent with Stripe’s authoritative state.

The third responsibility is where most integrations fail. The local database says the customer is on the free tier. Stripe says they paid for the upgrade. A webhook was sent but the handler crashed. The customer sees the free tier limits on a subscription they are paying for. This chapter prevents that failure.

The Feature

A market organizer using the free tier clicks “Upgrade” in the Marketflow dashboard. They are redirected to a Stripe Checkout page where they enter payment information and subscribe at $29/month. Upon successful payment, their account is upgraded to the paid tier and the 20-vendor limit is removed. If they cancel, their subscription remains active until the end of the billing period, then downgrades to free. If a payment fails, they receive a notification and have a grace period before the downgrade.

The Decision

Stripe Checkout over embedded payment forms. Stripe Checkout is a hosted payment page that Stripe maintains. It handles card input, 3D Secure authentication, address collection, and compliance with PCI-DSS. Building a custom payment form with Stripe Elements provides more design control but requires PCI compliance self-assessment and handling edge cases that Stripe Checkout handles automatically. At the bootstrapping stage, Checkout is the correct choice.

Webhook-driven state over API polling. After a checkout session completes, the application could poll the Stripe API to check the subscription status. Polling is wasteful and introduces latency between payment and feature activation. Webhooks deliver the state change to the application within seconds of the event occurring.

The Implementation

Stripe Product and Price Setup

# backend/app/services/stripe_setup.py
# Run once to create the Stripe product and price
import stripe

from app.config import settings

stripe.api_key = settings.stripe_secret_key


def setup_stripe_products() -> None:
    """Create the Marketflow subscription product and price in Stripe."""
    # Check if product already exists
    products = stripe.Product.list(limit=1)
    for product in products.data:
        if product.metadata.get("app") == "marketflow":
            print(f"Product already exists: {product.id}")
            return

    product = stripe.Product.create(
        name="Marketflow Pro",
        description="Unlimited vendors, multiple markets, vendor fee collection",
        metadata={"app": "marketflow"},
    )

    price = stripe.Price.create(
        product=product.id,
        unit_amount=2900,  # $29.00
        currency="usd",
        recurring={"interval": "month"},
        metadata={"app": "marketflow", "tier": "paid"},
    )

    print(f"Product: {product.id}")
    print(f"Price: {price.id}")
    print("Save the price ID in your environment as STRIPE_PRICE_ID")


if __name__ == "__main__":
    setup_stripe_products()

Checkout Session Creation

# backend/app/routers/billing.py
import stripe
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.config import settings
from app.database import get_db
from app.dependencies import CurrentUser, require_organizer, get_organizer_profile
from app.models.organizer import OrganizerProfile, SubscriptionTier

stripe.api_key = settings.stripe_secret_key

router = APIRouter(prefix="/api/billing", tags=["billing"])


@router.post("/create-checkout-session")
async def create_checkout_session(
    db: AsyncSession = Depends(get_db),
    current_user: CurrentUser = Depends(require_organizer),
    organizer: OrganizerProfile = Depends(get_organizer_profile),
) -> dict[str, str]:
    if organizer.subscription_tier == SubscriptionTier.PAID:
        raise HTTPException(status_code=400, detail="Already on paid tier")

    # Create or retrieve Stripe customer
    if organizer.stripe_customer_id:
        customer_id = organizer.stripe_customer_id
    else:
        customer = stripe.Customer.create(
            email=current_user.email,
            metadata={
                "organizer_id": str(organizer.id),
                "user_id": str(current_user.id),
            },
        )
        organizer.stripe_customer_id = customer.id
        await db.commit()
        customer_id = customer.id

    session = stripe.checkout.Session.create(
        customer=customer_id,
        line_items=[
            {
                "price": settings.stripe_price_id,
                "quantity": 1,
            }
        ],
        mode="subscription",
        success_url=f"{settings.frontend_url}/dashboard/settings?billing=success",
        cancel_url=f"{settings.frontend_url}/dashboard/settings?billing=cancelled",
        metadata={
            "organizer_id": str(organizer.id),
        },
    )

    return {"checkout_url": session.url}

Customer Portal for Management

# backend/app/routers/billing.py (continued)

@router.post("/create-portal-session")
async def create_portal_session(
    organizer: OrganizerProfile = Depends(get_organizer_profile),
) -> dict[str, str]:
    if not organizer.stripe_customer_id:
        raise HTTPException(status_code=400, detail="No billing account found")

    session = stripe.billing_portal.Session.create(
        customer=organizer.stripe_customer_id,
        return_url=f"{settings.frontend_url}/dashboard/settings",
    )

    return {"portal_url": session.url}

The customer portal lets organizers update payment methods, view invoices, and cancel subscriptions. Stripe hosts the portal. No custom UI needed.

Webhook Handler

# backend/app/routers/webhooks.py
import logging
import uuid

import stripe
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.config import settings
from app.database import get_db
from app.models.organizer import OrganizerProfile, SubscriptionTier

logger = logging.getLogger(__name__)

stripe.api_key = settings.stripe_secret_key

router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])


@router.post("/stripe")
async def stripe_webhook(
    request: Request,
    db: AsyncSession = Depends(get_db),
) -> dict[str, str]:
    payload = await request.body()
    sig_header = request.headers.get("stripe-signature")

    # SAFE: Always verify webhook signature
    try:
        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")

    # Idempotency: check if we already processed this event
    event_id = event["id"]
    # In production, store processed event IDs in a database table
    # For now, we handle idempotency at the business logic level

    event_type = event["type"]
    logger.info(f"Processing Stripe event: {event_type} ({event_id})")

    handlers = {
        "checkout.session.completed": handle_checkout_completed,
        "customer.subscription.created": handle_subscription_created,
        "customer.subscription.updated": handle_subscription_updated,
        "customer.subscription.deleted": handle_subscription_deleted,
        "invoice.payment_failed": handle_payment_failed,
    }

    handler = handlers.get(event_type)
    if handler:
        await handler(event["data"]["object"], db)
    else:
        logger.info(f"Unhandled event type: {event_type}")

    return {"status": "processed"}


async def handle_checkout_completed(
    session: dict,
    db: AsyncSession,
) -> None:
    """Checkout session completed. Subscription is now active."""
    customer_id = session["customer"]
    subscription_id = session["subscription"]

    result = await db.execute(
        select(OrganizerProfile).where(
            OrganizerProfile.stripe_customer_id == customer_id
        )
    )
    organizer = result.scalar_one_or_none()
    if not organizer:
        logger.error(f"No organizer found for Stripe customer {customer_id}")
        return

    organizer.stripe_subscription_id = subscription_id
    organizer.subscription_tier = SubscriptionTier.PAID
    await db.commit()
    logger.info(f"Organizer {organizer.id} upgraded to paid tier")


async def handle_subscription_created(
    subscription: dict,
    db: AsyncSession,
) -> None:
    """New subscription created. Update local state."""
    customer_id = subscription["customer"]

    result = await db.execute(
        select(OrganizerProfile).where(
            OrganizerProfile.stripe_customer_id == customer_id
        )
    )
    organizer = result.scalar_one_or_none()
    if not organizer:
        logger.error(f"No organizer for customer {customer_id}")
        return

    organizer.stripe_subscription_id = subscription["id"]

    if subscription["status"] in ("active", "trialing"):
        organizer.subscription_tier = SubscriptionTier.PAID
    await db.commit()


async def handle_subscription_updated(
    subscription: dict,
    db: AsyncSession,
) -> None:
    """Subscription changed (upgrade, downgrade, payment method update)."""
    customer_id = subscription["customer"]

    result = await db.execute(
        select(OrganizerProfile).where(
            OrganizerProfile.stripe_customer_id == customer_id
        )
    )
    organizer = result.scalar_one_or_none()
    if not organizer:
        logger.error(f"No organizer for customer {customer_id}")
        return

    status = subscription["status"]
    if status in ("active", "trialing"):
        organizer.subscription_tier = SubscriptionTier.PAID
    elif status in ("past_due", "unpaid"):
        # Grace period: keep paid features but notify
        logger.warning(f"Organizer {organizer.id} subscription is {status}")
    elif status == "canceled":
        organizer.subscription_tier = SubscriptionTier.FREE
        organizer.stripe_subscription_id = None

    await db.commit()


async def handle_subscription_deleted(
    subscription: dict,
    db: AsyncSession,
) -> None:
    """Subscription canceled and period ended. Downgrade to free."""
    customer_id = subscription["customer"]

    result = await db.execute(
        select(OrganizerProfile).where(
            OrganizerProfile.stripe_customer_id == customer_id
        )
    )
    organizer = result.scalar_one_or_none()
    if not organizer:
        logger.error(f"No organizer for customer {customer_id}")
        return

    organizer.subscription_tier = SubscriptionTier.FREE
    organizer.stripe_subscription_id = None
    await db.commit()
    logger.info(f"Organizer {organizer.id} downgraded to free tier")


async def handle_payment_failed(
    invoice: dict,
    db: AsyncSession,
) -> None:
    """Payment failed. Notify the organizer."""
    customer_id = invoice["customer"]

    result = await db.execute(
        select(OrganizerProfile).where(
            OrganizerProfile.stripe_customer_id == customer_id
        )
    )
    organizer = result.scalar_one_or_none()
    if not organizer:
        return

    logger.warning(
        f"Payment failed for organizer {organizer.id}, "
        f"invoice {invoice['id']}"
    )
    # Send notification email (Chapter 10)
    # Do NOT downgrade immediately. Stripe retries failed payments
    # according to its retry schedule (Smart Retries).
    # The subscription status will update via subscription.updated webhook.

Idempotency

Stripe can deliver the same webhook event multiple times. Network timeouts, retries, and edge cases in Stripe’s delivery system mean your handler must produce the same result whether it processes an event once or three times.

# TRAP: Non-idempotent handler that creates duplicate records
async def handle_checkout_completed(session: dict, db: AsyncSession) -> None:
    # If this runs twice, it creates two subscription records
    organizer = OrganizerProfile(
        stripe_customer_id=session["customer"],
        subscription_tier=SubscriptionTier.PAID,
    )
    db.add(organizer)
    await db.commit()
# SAFE: Idempotent handler that updates existing records
async def handle_checkout_completed(session: dict, db: AsyncSession) -> None:
    # Looking up by customer_id and updating is naturally idempotent
    # Running this twice produces the same database state
    result = await db.execute(
        select(OrganizerProfile).where(
            OrganizerProfile.stripe_customer_id == session["customer"]
        )
    )
    organizer = result.scalar_one_or_none()
    if not organizer:
        return  # Log error, but do not create a new record

    organizer.subscription_tier = SubscriptionTier.PAID
    organizer.stripe_subscription_id = session["subscription"]
    await db.commit()

The idempotent version uses UPDATE (via attribute assignment on an existing row), not INSERT. Running it twice sets the same values twice, which produces the same database state.

For stronger idempotency guarantees, store processed event IDs:

# backend/app/models/webhook_event.py
class ProcessedWebhookEvent(Base):
    __tablename__ = "processed_webhook_events"

    event_id: Mapped[str] = mapped_column(String(255), primary_key=True)
    event_type: Mapped[str] = mapped_column(String(100))
    processed_at: Mapped[datetime] = mapped_column(server_default=text("now()"))
# In the webhook handler, before processing:
existing = await db.execute(
    select(ProcessedWebhookEvent).where(
        ProcessedWebhookEvent.event_id == event["id"]
    )
)
if existing.scalar_one_or_none():
    logger.info(f"Event {event['id']} already processed, skipping")
    return {"status": "already_processed"}

# Process the event, then record it
db.add(ProcessedWebhookEvent(
    event_id=event["id"],
    event_type=event["type"],
))
await db.commit()

The Trap

# TRAP: Unverified webhook handler
@router.post("/stripe")
async def stripe_webhook(request: Request, db: AsyncSession = Depends(get_db)):
    data = await request.json()
    event_type = data["type"]  # Anyone can send a fake event
    # An attacker sends {"type": "checkout.session.completed", ...}
    # and gets free access to the paid tier
# SAFE: Signature verification (shown in the implementation above)
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
event = stripe.Webhook.construct_event(
    payload, sig_header, settings.stripe_webhook_secret
)
# Only events signed by Stripe's secret are processed

An unverified webhook endpoint accepts any HTTP POST with a JSON body. An attacker who discovers the endpoint URL can send a fake checkout.session.completed event and upgrade any account to paid. Signature verification is not optional. It is the authentication layer for webhooks.

The Cost

ComponentCost
Stripe subscription fee2.9% + $0.30 per transaction
Per $29/month subscription~$1.14 per transaction
Stripe Checkout$0 (included)
Stripe Customer Portal$0 (included)
Stripe CLI$0 (free)

At 50 paying customers ($1,450/month revenue), Stripe fees total approximately $57/month. At 500 paying customers ($14,500/month revenue), fees total approximately $570/month. Stripe’s pricing is percentage-based, which means it scales with revenue rather than requiring upfront commitment.