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:


Quick Fix / Quick Setup

bash
# 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_ID

Recommended app logic:

txt
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 it

Important 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:

  1. Immediate cancellation
  2. 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=true was 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:

txt
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_at
Signup
Trialing
Active
Past Due
Canceled

Subscription States

txt
active
  -> cancel_at_period_end=true
      -> resumed
      -> period ends -> canceled
  -> immediate cancel -> canceled

Step-by-step implementation

1. Create cancel and resume endpoints

Example routes:

txt
POST /api/billing/subscription/cancel
POST /api/billing/subscription/resume
POST /api/billing/subscription/cancel-now   # optional

2. Fetch local subscription by authenticated user

Example pseudo-code:

ts
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.

ts
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:

ts
const updated = await stripe.subscriptions.update(stripeSub.id, {
  cancel_at_period_end: true
})

Store the returned fields locally for immediate UI feedback:

ts
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:

txt
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 active or trialing
  • cancel_at_period_end=true
  • current time is before current_period_end

Example:

ts
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:

ts
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.

ts
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.updated
  • customer.subscription.deleted

Webhook example:

ts
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:

ts
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_end and current_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_due or unpaid
  • 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:

bash
stripe subscriptions retrieve SUBSCRIPTION_ID

Schedule cancellation:

bash
stripe subscriptions update SUBSCRIPTION_ID --cancel-at-period-end=true

Resume scheduled cancellation:

bash
stripe subscriptions update SUBSCRIPTION_ID --cancel-at-period-end=false

Immediate cancel:

bash
stripe subscriptions cancel SUBSCRIPTION_ID

Inspect recent events:

bash
stripe events list --limit 20

Forward webhooks locally:

bash
stripe listen --forward-to localhost:8000/webhooks/stripe

Call your app endpoints:

bash
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:

bash
grep -i "customer.subscription" /var/log/yourapp/app.log

Check the mirrored DB state:

bash
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.updated arrive?
  • did the event pass signature verification?
  • did idempotency logic skip or duplicate processing?
  • is current_period_end stored 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


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_end before 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.