TL;DR: Stripe emits 35+ subscription-related events. For a typical SaaS, you need exactly 4: customer.subscription.created (activation), invoice.paid (renewals), customer.subscription.updated (plan changes), and customer.subscription.deleted (cancellation or churn). Handle these 4 with idempotency checks and you've covered the full subscription lifecycle. Skip any one of them and you'll have customers with wrong access levels. This post shows the handlers we use in production.
Key takeaways:
- 4 events cover the full subscription lifecycle for most SaaS products
- Each event maps to a specific user-state change in your database
- Idempotency is not optional: Stripe retries failed webhooks up to 3 days
- Handle
customer.subscription.updatedor you'll miss upgrades and downgrades - The idempotency pattern from our previous post applies to all 4 events
Here's the failure mode this post fixes. Your customer canceled their subscription through your billing portal. Stripe sent a customer.subscription.deleted event. Your server returned a 500 because of an unrelated database connection issue. Stripe retried 3 hours later. By then your server was back up. But the retry landed on a different handler than the first attempt. The customer's access never got revoked. They email you a week later, confused about their bill. You're looking at your webhook logs trying to figure out what happened.
Look — this is the class of bug that doesn't show up in testing. It shows up at 11pm when a user tweets about it.
The full subscription lifecycle in one table
Before we look at each handler, here's the map. Every subscription follows this path, and each step has a corresponding Stripe event:
| Lifecycle Stage | Stripe Event | User State Change | Database Action |
|---|---|---|---|
| Initial purchase | customer.subscription.created |
Free → Active | Set plan, status=active, period_end |
| Monthly renewal | invoice.paid |
Active → Active (extended) | Update period_end, confirm access |
| Plan change | customer.subscription.updated |
Plan A → Plan B | Update plan, adjust entitlements |
| Cancellation or churn | customer.subscription.deleted |
Active → Canceled | Set status=canceled, revoke access |
You'll notice I'm not including checkout.session.completed in this table. That's covered in the previous post in this series for one-time products. For subscriptions, customer.subscription.created is the cleaner activation signal because it gives you the full subscription object with current_period_end, items, and the customer ID all in one place.
Event 1: customer.subscription.created
This fires when a subscription is created. For most setups, this happens immediately after a successful checkout. It's your signal to provision access.
The critical fields you need from this event: subscription.id, subscription.customer, subscription.status, subscription.current_period_end, and subscription.items.data[0].price.id (the plan they bought).
def handle_subscription_created(event_data: dict, db) -> None:
sub = event_data["object"]
sub_id = sub["id"]
# Idempotency check — return early if already processed
existing = db.table("subscription_events").select("id").eq("stripe_event_id", event_data["id"]).execute()
if existing.data:
return
customer_id = sub["customer"]
price_id = sub["items"]["data"][0]["price"]["id"]
plan_name = resolve_plan_name(price_id) # your mapping: price_id -> "pro" | "starter"
period_end = sub["current_period_end"]
# Update user record
db.table("users").update({
"subscription_id": sub_id,
"subscription_status": sub["status"],
"plan": plan_name,
"subscription_period_end": period_end,
"updated_at": "now()"
}).eq("stripe_customer_id", customer_id).execute()
# Record the event for idempotency
db.table("subscription_events").insert({
"stripe_event_id": event_data["id"],
"event_type": "subscription.created",
"subscription_id": sub_id,
"customer_id": customer_id,
"processed_at": "now()"
}).execute()
One thing worth noting: sub["status"] can be "trialing" if you're using Stripe trials. Handle that case explicitly by setting entitlements based on plan_name regardless of trial vs. active status, and let the customer.subscription.deleted handler revoke access when the trial expires without conversion.
Event 2: invoice.paid
This is your renewal signal. Every billing cycle, Stripe generates an invoice and tries to collect payment. When it succeeds, invoice.paid fires. This is how you keep subscription_period_end current in your database and ensure long-time subscribers don't get accidentally locked out after a renewal.
Important: filter for subscription invoices only. Stripe also fires invoice.paid for one-time invoices, metered billing, and other billing types. Check invoice["subscription"] exists before processing.
def handle_invoice_paid(event_data: dict, db) -> None:
invoice = event_data["object"]
# Skip non-subscription invoices
if not invoice.get("subscription"):
return
sub_id = invoice["subscription"]
customer_id = invoice["customer"]
# Idempotency check
existing = db.table("subscription_events").select("id").eq("stripe_event_id", event_data["id"]).execute()
if existing.data:
return
# Fetch the subscription to get current period_end
import stripe
sub = stripe.Subscription.retrieve(sub_id)
period_end = sub["current_period_end"]
db.table("users").update({
"subscription_status": "active",
"subscription_period_end": period_end,
"updated_at": "now()"
}).eq("stripe_customer_id", customer_id).execute()
db.table("subscription_events").insert({
"stripe_event_id": event_data["id"],
"event_type": "invoice.paid",
"subscription_id": sub_id,
"customer_id": customer_id,
"processed_at": "now()"
}).execute()
You'll notice the handler fetches the subscription object to get current_period_end. The invoice object itself has period_end, but that's the invoice billing period, not the subscription access period. Fetching the subscription directly avoids the subtle off-by-one where you set the wrong expiry date for annual subscribers paying in arrears.
Event 3: customer.subscription.updated
This is the event most tutorials skip. It fires when anything about a subscription changes: plan upgrade, plan downgrade, quantity change, trial end, cancellation scheduled for end of period. If you don't handle it, customers who upgrade stay on their old plan in your system.
The key field here is items.data[0].price.id, which tells you the new plan. Also check cancel_at_period_end: if it's true, the customer hit "cancel" but Stripe is waiting until the billing period ends. They're still active for now, but you should flag them in your database so you can trigger a cancellation-prevention flow.
def handle_subscription_updated(event_data: dict, db) -> None:
sub = event_data["object"]
sub_id = sub["id"]
customer_id = sub["customer"]
# Idempotency check
existing = db.table("subscription_events").select("id").eq("stripe_event_id", event_data["id"]).execute()
if existing.data:
return
new_price_id = sub["items"]["data"][0]["price"]["id"]
new_plan = resolve_plan_name(new_price_id)
new_status = sub["status"]
period_end = sub["current_period_end"]
cancel_at_period_end = sub.get("cancel_at_period_end", False)
db.table("users").update({
"plan": new_plan,
"subscription_status": new_status,
"subscription_period_end": period_end,
"cancel_scheduled": cancel_at_period_end,
"updated_at": "now()"
}).eq("stripe_customer_id", customer_id).execute()
db.table("subscription_events").insert({
"stripe_event_id": event_data["id"],
"event_type": "subscription.updated",
"subscription_id": sub_id,
"customer_id": customer_id,
"new_plan": new_plan,
"cancel_at_period_end": cancel_at_period_end,
"processed_at": "now()"
}).execute()
The cancel_scheduled flag is worth storing separately from subscription_status. It lets you trigger win-back emails before the period ends rather than finding out after the fact.
Event 4: customer.subscription.deleted
This fires in two situations: immediate cancellation (rare, usually admin-initiated), or end-of-period cancellation when cancel_at_period_end was true and the period expired. Either way, access should be revoked.
def handle_subscription_deleted(event_data: dict, db) -> None:
sub = event_data["object"]
sub_id = sub["id"]
customer_id = sub["customer"]
# Idempotency check
existing = db.table("subscription_events").select("id").eq("stripe_event_id", event_data["id"]).execute()
if existing.data:
return
db.table("users").update({
"subscription_status": "canceled",
"plan": "free",
"subscription_period_end": None,
"cancel_scheduled": False,
"updated_at": "now()"
}).eq("stripe_customer_id", customer_id).execute()
db.table("subscription_events").insert({
"stripe_event_id": event_data["id"],
"event_type": "subscription.deleted",
"subscription_id": sub_id,
"customer_id": customer_id,
"processed_at": "now()"
}).execute()
One note on the plan: "free" update: only do this if your product has a free tier. If you don't have a free tier, set plan to "none" or null and use the UI to gate access based on subscription_status == "canceled" directly. Setting a user to "free" when there's no free tier creates a ghost entitlement that's hard to audit later.
Wiring all 4 into one webhook dispatcher
Here's how we route all 4 events from a single FastAPI endpoint. The dispatcher pattern keeps each handler testable in isolation while sharing the database connection and signature verification.
from fastapi import FastAPI, Request, HTTPException
import stripe
import os
from supabase import create_client
app = FastAPI()
supabase = create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_SERVICE_ROLE_KEY"])
WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"]
HANDLERS = {
"customer.subscription.created": handle_subscription_created,
"invoice.paid": handle_invoice_paid,
"customer.subscription.updated": handle_subscription_updated,
"customer.subscription.deleted": handle_subscription_deleted,
}
@app.post("/stripe/webhook")
async def stripe_webhook(request: Request):
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
try:
event = stripe.Webhook.construct_event(payload, sig_header, WEBHOOK_SECRET)
except stripe.error.SignatureVerificationError:
raise HTTPException(status_code=400, detail="Invalid signature")
event_type = event["type"]
handler = HANDLERS.get(event_type)
if handler:
try:
handler(event["data"], supabase)
except Exception as e:
# Log but return 200 to prevent Stripe retry storm
print(f"Handler error for {event_type}: {e}")
# Re-raise only for non-idempotent errors; swallow duplicate-key errors
if "duplicate" not in str(e).lower():
raise
return {"status": "ok"}
The error handling at the bottom deserves explanation. We return 200 on handler errors except for truly unexpected failures. This looks wrong but it's correct. If you return 4xx or 5xx, Stripe retries for up to 3 days. For a non-idempotent handler with a bug, that means running the same broken code 18 times. Better to log the error, fix the bug, and replay the event manually from the Stripe dashboard.
Duplicate-key errors specifically get swallowed. Those indicate the idempotency check caught a replay, which is working exactly as intended.
The schema behind the handlers
Here's the minimal Supabase schema that supports all 4 handlers. The subscription_events table is the idempotency layer; the users table carries the current subscription state.
-- Idempotency log (one row per processed Stripe event)
CREATE TABLE subscription_events (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
stripe_event_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
subscription_id TEXT,
customer_id TEXT,
new_plan TEXT,
cancel_at_period_end BOOLEAN,
processed_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_sub_events_stripe_event ON subscription_events(stripe_event_id);
CREATE INDEX idx_sub_events_customer ON subscription_events(customer_id);
-- Subscription state on users table (add these columns if they don't exist)
ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_customer_id TEXT UNIQUE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_id TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_status TEXT DEFAULT 'free';
ALTER TABLE users ADD COLUMN IF NOT EXISTS plan TEXT DEFAULT 'free';
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_period_end TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN IF NOT EXISTS cancel_scheduled BOOLEAN DEFAULT false;
CREATE INDEX idx_users_stripe_customer ON users(stripe_customer_id);
The UNIQUE constraint on subscription_events.stripe_event_id is what makes the idempotency check fast. It's a database-level guarantee: even if two concurrent webhook deliveries race each other, only one insert will succeed. The second will hit a unique violation, which the dispatcher catches and swallows.
What to watch for in production
Three things that trip up this setup in production:
- Webhook delivery order isn't guaranteed. Stripe can deliver
customer.subscription.updatedbeforecustomer.subscription.createdif there's a millisecond race on initial checkout. Handle this by checking whether the user record exists before updating, and creating a minimal record if it doesn't. Thesubscription.createdhandler will fill in the rest when it arrives. - Trial conversions fire subscription.updated, not subscription.created. When a trial converts to a paid subscription, Stripe updates the existing subscription object rather than creating a new one. Your
subscription.updatedhandler needs to handle thestatuschange from"trialing"to"active"explicitly, including updating entitlements if trialing and paid have different feature sets. - Failed payment creates subscription.updated, not subscription.deleted. When a payment fails, Stripe puts the subscription into
"past_due"status and firessubscription.updated. It only firessubscription.deletedif the subscription isn't recovered after the dunning period. If you want to restrict access during past-due state, check forstatus == "past_due"in yoursubscription.updatedhandler.
Frequently asked questions
Should I handle invoice.payment_succeeded or invoice.paid for renewals? Use invoice.paid. It fires when the invoice status changes to "paid," which includes both successful automatic payment and manually marked invoices. invoice.payment_succeeded only covers automatic payments and misses edge cases like manual payment, Stripe credit applied, and trial-to-paid conversions.
Do I need to handle customer.subscription.created if I already handle checkout.session.completed? Depends. If you sell subscriptions exclusively through Stripe Checkout, checkout.session.completed fires first and you could provision access there. But subscription.created gives you cleaner subscription data (period end, plan ID, items) without having to fetch additional objects. We use both: checkout.session.completed for one-time products, subscription.created for recurring.
What happens if my server goes down and Stripe retries an event my handler already processed? The idempotency check catches it. Stripe includes the same event ID in the retry. Your handler finds the existing row in subscription_events and returns early before touching user data. The UNIQUE constraint is your safety net if two retries arrive simultaneously.
How long does Stripe retry failed webhooks? Up to 3 days, with exponential backoff. The first retry is 5 minutes after failure, then 30 minutes, then escalating over 72 hours. This is why returning 200 even on non-critical handler errors is usually the right call: you have 72 hours to fix and replay, rather than Stripe exhausting retries on broken code.
Next in this series: handling Stripe customer portal flows, where customers change their plan or cancel through Stripe's hosted UI rather than your own interface. The events are the same, but the sequence differs when plan changes take effect immediately versus at period end.
If you want this entire setup pre-built, tested, and wired into your stack in 7 days, the Quick Stripe Fulfillment blueprint covers the full lifecycle: one-time purchases, subscription activation, plan changes, renewals, and cancellation. $297 flat, 30-day fix-or-refund guarantee.