Understanding Subscription Lifecycle Events
Handling subscriptions correctly means listening to the right Stripe events at the right time. Subscription lifecycle events: best practices explained is about knowing which events trigger critical business logic—and which ones don't. We'll walk through the core events that matter in production, show you common pitfalls, and give you the patterns we've used across e-commerce, SaaS, and ride-hailing platforms.
Most payment integrations fail not because Stripe is difficult, but because teams handle events inconsistently or out of order. You might grant access on the wrong event, fail to catch dunning scenarios, or leave orphaned subscriptions in your database. This guide covers the events that genuinely impact revenue and user experience.
Customer.subscription.created and Updated
When a customer first subscribes, Stripe fires customer.subscription.created. This is your signal to initialize subscription state in your system—but not yet to grant access. The subscription object contains the plan, currency, interval, and billing cycle dates. You need this in your database before anything else happens.
The distinction between created and updated matters. updated fires when the customer changes plans, updates their card, or modifies quantity. Both events include the full subscription object, so you can upsert your local subscription record safely.
Here's how we typically handle both:
async function handleSubscriptionEvent(
event: Stripe.Event,
subscriptionId: string
): Promise {
const subscription = event.data.object as Stripe.Subscription;
// Upsert subscription record
await db.subscriptions.upsert({
where: { stripeSubscriptionId: subscription.id },
update: {
customerId: subscription.customer as string,
status: subscription.status,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
planId: subscription.items.data[0]?.price.id,
quantity: subscription.items.data[0]?.quantity || 1,
updatedAt: new Date(),
},
create: {
stripeSubscriptionId: subscription.id,
customerId: subscription.customer as string,
status: subscription.status,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
planId: subscription.items.data[0]?.price.id,
quantity: subscription.items.data[0]?.quantity || 1,
},
});
// Log for audit trail
await db.events.create({
data: {
subscriptionId,
eventType: event.type,
stripeEventId: event.id,
rawPayload: subscription,
},
});
}
Notice we don't grant access here. We're recording the subscription intent. Access happens later, after payment succeeds. This separation is crucial for handling failed charges cleanly.
On updated events, you might also need to handle downgrades or plan changes. Some teams pro-rate immediately; others wait for the next billing cycle. Store the change reason in your audit log so your support team can trace what happened.
Invoice.paid Grants Access
This is where access lives. When Stripe fires invoice.paid, the payment succeeded. For new subscriptions, the first invoice is often created immediately after the subscription, and invoice.paid confirms the customer actually has funds. For recurring subscriptions, each renewal generates a new invoice that must be paid before the user keeps access.
We've seen teams grant access on subscription.created and regret it when the payment fails. Stripe's subscription model is forgiving: if the initial charge fails, the subscription sits in an incomplete state until payment is retried or succeeds. Your access control must respect this.
Here's a production pattern:
async function handleInvoicePaid(event: Stripe.Event): Promise {
const invoice = event.data.object as Stripe.Invoice;
if (invoice.subscription) {
const subscription = await db.subscriptions.findUnique({
where: { stripeSubscriptionId: invoice.subscription as string },
});
if (!subscription) {
console.error(`Subscription not found: ${invoice.subscription}`);
return;
}
// Grant access
await db.users.update({
where: { id: subscription.userId },
data: {
subscriptionStatus: 'active',
accessGrantedAt: new Date(),
nextBillingDate: new Date(invoice.period_end * 1000),
},
});
// Clear any dunning state
await db.dunning.deleteMany({
where: { userId: subscription.userId },
});
// Track invoice payment
await db.invoices.upsert({
where: { stripeInvoiceId: invoice.id },
update: {
status: 'paid',
paidAt: new Date(invoice.paid * 1000),
},
create: {
stripeInvoiceId: invoice.id,
subscriptionId: subscription.id,
status: 'paid',
amount: invoice.amount_paid || 0,
currency: invoice.currency,
paidAt: new Date(invoice.paid * 1000),
},
});
// Emit access-granted event for downstream services
await events.publish({
type: 'subscription.access_granted',
userId: subscription.userId,
timestamp: new Date(),
});
}
}
Key details: We only grant access after confirming the subscription exists locally. We clear dunning records (more on that next). We emit an internal event so other services—logging, analytics, provisioning—can react. And we record the invoice for audit and accounting.
One subtlety: invoices can be paid manually, auto-collected, or custom. Stripe's invoice.paid event covers all paths. Your code doesn't need to care how—just that it's paid.
Invoice.payment_failed Starts Dunning
Payment failures are inevitable. Networks hiccup, cards expire, accounts get closed. Stripe automatically retries failed payments on a schedule you configure in your account settings. But your application needs to know a payment failed so you can warn the customer and nudge them to update their billing method.
When invoice.payment_failed fires, the initial charge attempt failed. Stripe isn't done retrying yet—by default, it retries several more times over days. But the subscription status might shift to past_due or unpaid. Your job is to log it, notify the user, and possibly downgrade or restrict access depending on your policy.
We typically start a dunning sequence:
async function handleInvoicePaymentFailed(event: Stripe.Event): Promise {
const invoice = event.data.object as Stripe.Invoice;
if (invoice.subscription) {
const subscription = await db.subscriptions.findUnique({
where: { stripeSubscriptionId: invoice.subscription as string },
});
if (!subscription) {
return;
}
const user = await db.users.findUnique({
where: { id: subscription.userId },
});
// Start dunning process
const dunningRecord = await db.dunning.upsert({
where: { userId: user.id },
update: {
attemptCount: { increment: 1 },
lastFailedAt: new Date(),
invoiceId: invoice.id,
},
create: {
userId: user.id,
subscriptionId: subscription.id,
attemptCount: 1,
lastFailedAt: new Date(),
invoiceId: invoice.id,
status: 'retrying',
},
});
// Send email
await mailer.send({
to: user.email,
template: 'payment-failed',
data: {
name: user.name,
amount: invoice.amount_due,
currency: invoice.currency,
updateUrl: `${process.env.APP_URL}/billing/update-payment`,
},
});
// Restrict access if policy requires it
const daysSinceFail = Math.floor(
(Date.now() - dunningRecord.lastFailedAt.getTime()) / (1000 * 60 * 60 * 24)
);
if (daysSinceFail >= 3) {
await db.users.update({
where: { id: user.id },
data: { subscriptionStatus: 'suspended' },
});
}
// Log for compliance
await db.events.create({
data: {
userId: user.id,
eventType: 'invoice.payment_failed',
metadata: {
stripeInvoiceId: invoice.id,
amount: invoice.amount_due,
attemptCount: dunningRecord.attemptCount,
},
},
});
}
}
Notice we don't immediately revoke access on the first failure. We log it, notify the customer, and only restrict after a grace period (typically 3–7 days depending on your SLA). This is more forgiving and often recovers the subscription when the customer updates their card.
Some teams use webhooks to track retry attempts and escalate notifications. If the third retry fails, you might send a stronger message or even restrict features (read-only mode) to push the user to act before access is fully revoked.
Customer.subscription.deleted Removes Access
When a subscription is cancelled—by the customer, by you via the API, or by Stripe due to repeated payment failures—customer.subscription.deleted fires. This is your signal to revoke access immediately and clean up.
The key distinction: a subscription can end gracefully (customer cancelled) or forcefully (failed payments after dunning). Your system should handle both the same way: remove access, record the reason, and notify the customer.
async function handleSubscriptionDeleted(event: Stripe.Event): Promise {
const subscription = event.data.object as Stripe.Subscription;
const dbSubscription = await db.subscriptions.findUnique({
where: { stripeSubscriptionId: subscription.id },
});
if (!dbSubscription) {
return;
}
const user = await db.users.findUnique({
where: { id: dbSubscription.userId },
});
// Mark subscription as cancelled
await db.subscriptions.update({
where: { id: dbSubscription.id },
data: {
status: 'cancelled',
cancelledAt: new Date(),
cancelReason: subscription.cancellation_details?.reason || 'unknown',
},
});
// Revoke access
await db.users.update({
where: { id: user.id },
data: {
subscriptionStatus: 'cancelled',
accessRevokedAt: new Date(),
},
});
// Clean up resources
await revokeApiTokens(user.id);
await terminateActiveSessions(user.id);
// Notify customer
await mailer.send({
to: user.email,
template: 'subscription-cancelled',
data: {
name: user.name,
reason: subscription.cancellation_details?.reason,
reactivateUrl: `${process.env.APP_URL}/reactivate`,
},
});
// Log for analytics
await db.events.create({
data: {
userId: user.id,
eventType: 'customer.subscription.deleted',
metadata: {
cancellationReason: subscription.cancellation_details?.reason,
subscriptionLength: Math.floor(
(Date.now() - new Date(subscription.created * 1000).getTime()) /
(1000 * 60 * 60 * 24)
),
},
},
});
}
Revoke access immediately and thoroughly. This means terminating active sessions, invalidating tokens, and notifying any downstream services. If the customer comes back, they'll need to resubscribe, and your system should handle re-onboarding cleanly.
Common Mistakes
Granting access on subscription.created instead of invoice.paid: This is the most frequent error. A subscription can sit in incomplete state for days if payment fails. Your access control must wait for invoice.paid.
Ignoring idempotency: Webhook delivery isn't guaranteed to be exactly-once. Stripe may send the same event twice. Always use an idempotency key or check if the record already exists before creating or updating.
Not handling subscription.updated for plan changes: When a customer downgrades mid-cycle, you need to reflect that in your database. Missing updates lead to billing inconsistencies and support tickets.
Deleting subscription records instead of marking them cancelled: You need the history for accounting, taxes, and churn analysis. Soft-delete by setting a cancelledAt timestamp.
Immediate access revocation on payment failures: A single failed payment doesn't mean the customer is gone. Give them 3–7 days and a chance to update their card. Dunning works.
Key Takeaways
- Listen to four core events:
customer.subscription.createdandupdatedto track subscription state,invoice.paidto grant access,invoice.payment_failedto start dunning, andcustomer.subscription.deletedto revoke access. - Never grant access on subscription creation. Always wait for the first invoice to be paid.
- Implement a dunning sequence: log failures, email the customer, and only restrict access after several days or retry attempts.
- Use soft deletes: mark subscriptions and invoices as cancelled or paid rather than removing them, so you preserve audit trails.
- Treat all events as idempotent: check for existing records before updating, and use Stripe's event ID to detect duplicates.
- Emit internal events from your webhook handlers so other services (logging, provisioning, analytics) can react without coupling.






Comments
Loading comments…