Cancel and Resume Subscriptions
The essential playbook for implementing cancel and resume subscriptions in your SaaS.
Intro
This page shows how to implement cancel and resume subscription flows with Stripe without breaking billing state, entitlement checks, or local records.
Use this model:
- Stripe is the billing source of truth
- Your database mirrors the Stripe subscription state
- Entitlements update only after verified webhook events
- Default SaaS behavior is usually cancel at period end, not immediate cancellation
This page pairs well with:
- Stripe Subscription Setup (Step-by-Step)
- Handling Webhooks Correctly
- Managing Subscription States
- SaaS Production Checklist
Quick Fix / Quick Setup
# Cancel at period end
stripe subscriptions update SUBSCRIPTION_ID \
--cancel-at-period-end=true
# Resume a subscription that is scheduled to cancel at period end
stripe subscriptions update SUBSCRIPTION_ID \
--cancel-at-period-end=false
# Immediate cancellation
stripe subscriptions cancel SUBSCRIPTION_IDRecommended app logic:
1. User clicks cancel -> set cancel_at_period_end=true
2. Keep access until current_period_end
3. Listen for customer.subscription.updated and customer.subscription.deleted
4. Update local DB fields: status, cancel_at_period_end, current_period_end, canceled_at
5. Only revoke access when Stripe status/webhook confirms itImportant notes:
- Default behavior should usually be period-end cancellation
- Resume should only be available while the subscription is still active and scheduled to end
- Do not rely on frontend state alone
- Reconcile local state from Stripe webhooks
What’s happening
Stripe supports two cancellation paths:
- Immediate cancellation
- Cancellation at period end
For most SaaS products, period-end cancellation is safer because:
- the customer keeps access through the paid term
- support load is lower
- disputes are less likely
- entitlement logic is easier to explain
A resumed subscription is usually a subscription where:
cancel_at_period_end=truewas previously set- the billing period has not ended yet
- you update it back to
cancel_at_period_end=false
Do not treat a cancel button click as an entitlement change unless you explicitly support instant revocation.
Correct implementation requires:
- Stripe API mutation
- verified webhook processing
- local subscription state storage
- entitlement checks tied to normalized subscription data
Recommended local fields:
user_id
stripe_customer_id
stripe_subscription_id
stripe_price_id
status
cancel_at_period_end
current_period_end
current_period_start
canceled_at
trial_end
ended_at
last_webhook_event_id
updated_atSubscription States
active
-> cancel_at_period_end=true
-> resumed
-> period ends -> canceled
-> immediate cancel -> canceledStep-by-step implementation
1. Create cancel and resume endpoints
Example routes:
POST /api/billing/subscription/cancel
POST /api/billing/subscription/resume
POST /api/billing/subscription/cancel-now # optional2. Fetch local subscription by authenticated user
Example pseudo-code:
const subscription = await db.subscription.findUnique({
where: { userId: session.user.id }
})
if (!subscription?.stripeSubscriptionId) {
throw new Error("No active Stripe subscription found")
}If your app gates features by account or workspace, store billing at the org/account level, not only the user level. If needed, pair this with your auth layer from Implement User Authentication (Login/Register).
3. Retrieve Stripe subscription before mutating
Do not mutate based only on local assumptions.
const stripeSub = await stripe.subscriptions.retrieve(
subscription.stripeSubscriptionId
)Validate current state:
- subscription exists
- status is resumable/cancellable
- customer matches expected tenant
- action is allowed for current billing state
4. Cancel at period end
Node.js example:
const updated = await stripe.subscriptions.update(stripeSub.id, {
cancel_at_period_end: true
})Store the returned fields locally for immediate UI feedback:
await db.subscription.update({
where: { stripeSubscriptionId: updated.id },
data: {
status: updated.status,
cancelAtPeriodEnd: updated.cancel_at_period_end,
currentPeriodStart: new Date(updated.current_period_start * 1000),
currentPeriodEnd: new Date(updated.current_period_end * 1000),
canceledAt: updated.canceled_at ? new Date(updated.canceled_at * 1000) : null
}
})But do not finalize entitlement changes here. Final state should be confirmed by webhook.
5. Show the effective end date
After period-end cancellation, show exact copy:
Your plan will remain active until 2026-05-31.Use current_period_end, not a guessed date.
6. Resume before the end date
Allow resume only when:
- Stripe status is still
activeortrialing cancel_at_period_end=true- current time is before
current_period_end
Example:
if (
!["active", "trialing"].includes(stripeSub.status) ||
!stripeSub.cancel_at_period_end
) {
throw new Error("Subscription is not eligible for resume")
}
const resumed = await stripe.subscriptions.update(stripeSub.id, {
cancel_at_period_end: false
})Update local provisional state:
await db.subscription.update({
where: { stripeSubscriptionId: resumed.id },
data: {
status: resumed.status,
cancelAtPeriodEnd: resumed.cancel_at_period_end,
currentPeriodStart: new Date(resumed.current_period_start * 1000),
currentPeriodEnd: new Date(resumed.current_period_end * 1000),
canceledAt: resumed.canceled_at ? new Date(resumed.canceled_at * 1000) : null
}
})7. Optional: support immediate cancellation
Make this a separate action. Do not overload the normal cancel button.
const canceled = await stripe.subscriptions.cancel(stripeSub.id)Immediate cancellation can affect:
- access timing
- proration behavior
- invoice generation
- support expectations
If you offer it, add explicit warning text in the UI.
8. Implement webhook handlers
At minimum, handle:
customer.subscription.updatedcustomer.subscription.deleted
Webhook example:
import Stripe from "stripe"
import express from "express"
const app = express()
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"] as string,
endpointSecret
)
} catch (err: any) {
return res.status(400).send(`Webhook Error: ${err.message}`)
}
const alreadyProcessed = await db.stripeEvent.findUnique({
where: { eventId: event.id }
})
if (alreadyProcessed) {
return res.status(200).json({ received: true, deduped: true })
}
try {
switch (event.type) {
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription
await db.$transaction(async (tx) => {
await tx.subscription.upsert({
where: { stripeSubscriptionId: sub.id },
update: {
stripeCustomerId: String(sub.customer),
stripePriceId: sub.items.data[0]?.price?.id ?? null,
status: sub.status,
cancelAtPeriodEnd: sub.cancel_at_period_end,
currentPeriodStart: new Date(sub.current_period_start * 1000),
currentPeriodEnd: new Date(sub.current_period_end * 1000),
canceledAt: sub.canceled_at
? new Date(sub.canceled_at * 1000)
: null,
trialEnd: sub.trial_end ? new Date(sub.trial_end * 1000) : null,
endedAt: sub.ended_at ? new Date(sub.ended_at * 1000) : null,
updatedAt: new Date()
},
create: {
userId: "", // resolve this from your mapping
stripeSubscriptionId: sub.id,
stripeCustomerId: String(sub.customer),
stripePriceId: sub.items.data[0]?.price?.id ?? null,
status: sub.status,
cancelAtPeriodEnd: sub.cancel_at_period_end,
currentPeriodStart: new Date(sub.current_period_start * 1000),
currentPeriodEnd: new Date(sub.current_period_end * 1000),
canceledAt: sub.canceled_at
? new Date(sub.canceled_at * 1000)
: null,
trialEnd: sub.trial_end ? new Date(sub.trial_end * 1000) : null,
endedAt: sub.ended_at ? new Date(sub.ended_at * 1000) : null,
updatedAt: new Date()
}
})
await tx.stripeEvent.create({
data: {
eventId: event.id,
eventType: event.type
}
})
})
break
}
}
return res.status(200).json({ received: true })
} catch (err) {
return res.status(500).json({ error: "webhook_processing_failed" })
}
}
)9. Make entitlements depend on effective state
Do not use only status.
Example entitlement check:
function hasPaidAccess(sub: {
status: string
cancelAtPeriodEnd: boolean
currentPeriodEnd: Date | null
trialEnd?: Date | null
}) {
const now = new Date()
if (sub.status === "trialing") {
return !sub.trialEnd || now <= sub.trialEnd
}
if (sub.status === "active") {
if (!sub.cancelAtPeriodEnd) return true
return !!sub.currentPeriodEnd && now <= sub.currentPeriodEnd
}
return false
}This avoids early revocation when a subscription is active but scheduled to end.
10. Support Stripe Customer Portal actions
If users can cancel or resume through Stripe Customer Portal:
- do not assume changes originate from your app
- process the same webhook events
- keep your local UI driven by your mirrored subscription table
This is required even if your app already updates state from direct API actions.
Common causes
These are the most common failure patterns:
- using immediate cancellation when the product expects period-end cancellation
- revoking access as soon as the user clicks cancel
- not storing
cancel_at_period_endandcurrent_period_end - not processing
customer.subscription.updated - allowing resume after the subscription has fully ended
- using frontend state for entitlement decisions
- ignoring Stripe Customer Portal changes
- not handling
past_dueorunpaid - duplicate webhook processing because of missing idempotency
- confusing Stripe subscription status with entitlement state
Also review adjacent state handling in Managing Subscription States.
Debugging tips
Check the Stripe subscription directly:
stripe subscriptions retrieve SUBSCRIPTION_IDSchedule cancellation:
stripe subscriptions update SUBSCRIPTION_ID --cancel-at-period-end=trueResume scheduled cancellation:
stripe subscriptions update SUBSCRIPTION_ID --cancel-at-period-end=falseImmediate cancel:
stripe subscriptions cancel SUBSCRIPTION_IDInspect recent events:
stripe events list --limit 20Forward webhooks locally:
stripe listen --forward-to localhost:8000/webhooks/stripeCall your app endpoints:
curl -X POST https://your-app.example.com/api/billing/subscription/cancel \
-H 'Authorization: Bearer TOKEN'
curl -X POST https://your-app.example.com/api/billing/subscription/resume \
-H 'Authorization: Bearer TOKEN'Inspect logs:
grep -i "customer.subscription" /var/log/yourapp/app.logCheck the mirrored DB state:
psql "$DATABASE_URL" -c "select user_id, stripe_subscription_id, status, cancel_at_period_end, current_period_end, canceled_at from subscriptions order by updated_at desc limit 20;"Debug checklist:
- does Stripe show
cancel_at_period_end=true? - did
customer.subscription.updatedarrive? - did the event pass signature verification?
- did idempotency logic skip or duplicate processing?
- is
current_period_endstored in UTC consistently? - is your entitlement middleware using effective dates?
- was the subscription already fully canceled before resume?
For webhook reliability details, see Handling Webhooks Correctly.
Checklist
- ✓ period-end cancellation is supported
- ✓ resume unsets
cancel_at_period_end - ✓ immediate cancellation is separate and explicit
- ✓ webhook signature verification is enabled
- ✓ webhook processing is idempotent
- ✓ subscription fields are mirrored locally
- ✓ entitlement checks use effective dates, not only raw status
- ✓ billing UI shows exact access end date
- ✓ Customer Portal changes sync through webhooks
- ✓ support/admin tooling can inspect raw Stripe state
- ✓ fully canceled subscriptions create a new subscription instead of fake resume
- ✓ account/workspace entitlements update at the correct scope
Before launch, verify the rest of billing and production edge cases in:
Related guides
- Stripe Subscription Setup (Step-by-Step)
- Handling Webhooks Correctly
- Managing Subscription States
- Implement User Authentication (Login/Register)
- SaaS Production Checklist
FAQ
Should I cancel immediately or at period end?
For most small SaaS products, cancel at period end is the default. It preserves access through the paid term and reduces billing disputes.
How should resume work?
Resume should unset cancel_at_period_end before the current billing period ends. If the subscription is already fully canceled, create a new subscription.
Which Stripe webhooks matter here?
At minimum, handle customer.subscription.updated and customer.subscription.deleted. Also monitor invoice and payment events if entitlements depend on payment state.
Can I trust my local database over Stripe?
No. Stripe is the billing source of truth. Your local database should mirror Stripe state and drive app logic after successful synchronization.
What should my app show after a user cancels?
Show that the subscription remains active until the exact current_period_end timestamp and provide a resume option until that date.
Can I resume a fully canceled subscription?
Usually no. If the subscription has fully ended in Stripe, create a new subscription instead of trying to resume it.
What if a user cancels from Stripe Customer Portal?
Your app must process webhook events and update local state automatically.
Do I need both API response handling and webhooks?
Yes. Use API responses for immediate UX, but trust webhooks for final synchronization.
What if webhook delivery is delayed?
Persist the API response for temporary UI state, keep the user-facing message clear, and run periodic reconciliation against Stripe.
Final takeaway
The safest implementation is:
- period-end cancellation by default
- resume by unsetting
cancel_at_period_endbefore the billing period ends - webhook-driven synchronization
- entitlement checks based on effective access dates, not only raw status
Most bugs in this flow come from:
- mismatched local state
- missing or duplicate webhook processing
- revoking access too early
Stripe should be the billing source of truth. Your database should be the application source of truth only after successful synchronization.