Managing subscriptions: downgrade, cancel, renew

Two SDK methods write to subscriptions. They model two different product realities, and picking the right one keeps your metering behavior - and your history log - clean. This guide explains when to use which.

The two-method model

MethodWhat it doesUse when
upsertSubscription()Sets the user's current plan. Idempotent - same plan = no-op.Signup, upgrade, downgrade (including “cancel to free”), reactivation.
cancelSubscription()Sets ends_at so the subscription stops counting. After that point canUse / reserve return { allowed: false }.Apps that require a paid subscription and want canceled users to be hard-blocked from metered features.

The decision

The question is really: does your app have a free plan?

Pick one pattern per app and stick to it - mixing them for the same user produces a confusing history.

Pattern A - your app has a free plan

This covers most consumer apps and any “freemium” SaaS. The user always has a plan; cancellation is really a downgrade to the free tier. Don't call cancelSubscription - just call upsertSubscriptionwith your free plan's id.

// On Stripe cancellation, on user-initiated downgrade,
// on payment failure that drops them to free, etc.
await vevee.upsertSubscription({
  userId: 'user_abc123',
  planId: 'free',
});

What happens:

  • An entry is appended to the subscription history with event_type: 'plan_changed', from_plan_id: pro, to_plan_id: free. Queryable later for churn analytics.
  • The user keeps using your app under free-tier limits.
  • Counter behavior at the switch follows whichever onPlanChange mode the free plan's limit groups are configured with - see upsertSubscription for the table.

Anti-abuse: use the block mode on free

A free → pro → “cancel” → fresh free quota loop is the classic abuse path. Set the free plan's onPlanChange mode to block in the dashboard: when a user downgrades, the free counters are pre-filled to quota and they have to wait for the next period rollover before getting more credits. Legitimate downgrades cost almost nothing; cyclers get nothing.

Pattern B - your app requires a paid subscription

B2B tools, enterprise software, anything where “no plan” means “you can't use the app.” Cancellation is a hard block: the user should not be able to consume any metered feature once they cancel.

await vevee.cancelSubscription({
  userId: 'user_abc123',
  reason: 'user_cancel',
});

What happens immediately:

  • subscriptions.ends_at is set to now. The current-state row stays - its plan_id is unchanged - but it's flagged as ended.
  • canUse() and reserve() return { allowed: false, matched: false, reasons: ['no_subscription'] }. Your existing if (!result.allowed) return ... guard handles the block - no client-side change needed beyond a “subscribe to continue” UI.
  • track() still records the event with match_status: 'no_subscription', but increments no counters and bills no cost. The dashboard will show the failed attempts so you can spot users hitting the wall.
  • An entry is appended to the subscription history with event_type: 'canceled', from_plan_id = their previous plan, to_plan_id: null, and your reason.

Scheduled cancellation (cancel at period end)

Common Stripe pattern: when a user cancels, let them keep using the plan until their current paid period ends. Pass that end time as endsAt:

// app/api/webhooks/stripe/route.ts
if (event.type === 'customer.subscription.updated') {
  const sub = event.data.object;
  if (sub.cancel_at_period_end) {
    await vevee.cancelSubscription({
      userId: sub.metadata.app_user_id,
      endsAt: new Date(sub.current_period_end * 1000).toISOString(),
      reason: 'stripe_period_end_cancel',
    });
  }
}

Until endsAt passes the user keeps full access. loadSubscription ignores ends_atwhile it's still in the future, so metering is unchanged. Want to change your mind before then? Call cancelSubscription again with a different endsAt, or call upsertSubscription to clear it entirely.

Aligning renewals with Stripe (or any billing provider)

Cancellation is one half of the lifecycle. The other half is renewals- and when Stripe (or Paddle, or your own billing layer) owns the real renewal date, you need Vevee's counters to reset on the same day, not on whatever the plan-level period_anchor says.

The drift problem

A user signs up on Jan 15 and gets monthly billing through Stripe. Stripe renews Feb 15, Mar 15, Apr 15. But the plan in your Vevee dashboard is configured with period_anchor = 'calendar' + monthly - so Vevee resets counters on Feb 1, Mar 1, Apr 1. The two clocks drift, and the user either sees a quota reset two weeks before they pay (over-served) or hits a wall a week before their next charge (under-served).

The fix: cycleStart

Pass cycleStart to upsertSubscription with the timestamp your billing provider reports as the start of the current cycle. Vevee pins that subscription to your billing clock. The next track / canUse / reserve recomputes the active period against the new anchor and writes a fresh counter row at the new period_start - lazy reset, no migration, no event-log deletion.

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { vevee } from '@/lib/vevee';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const sig = req.headers.get('stripe-signature')!;
  const buf = await req.arrayBuffer();
  const event = stripe.webhooks.constructEvent(
    Buffer.from(buf),
    sig,
    process.env.STRIPE_WEBHOOK_SECRET!,
  );

  // Both events carry current_period_start. Handling them identically
  // makes the integration trivially idempotent.
  if (
    event.type === 'customer.subscription.created' ||
    event.type === 'customer.subscription.updated'
  ) {
    const sub = event.data.object;
    const userId = sub.metadata.app_user_id; // however you stash this on checkout

    await vevee.upsertSubscription({
      userId,
      planId: planIdFromStripePrice(sub.items.data[0].price.id),
      cycleStart: new Date(sub.current_period_start * 1000).toISOString(),
    });
  }

  return new Response('ok');
}

How renewal day actually works

  • On the first webhook, cycleStart is set. The next time a user consumes a metered event, Vevee sees the new anchor and computes the period as [cycleStart, cycleStart + period], creating a fresh counter row at that period_start.
  • On every subsequent renewal, Stripe fires customer.subscription.updated with a new current_period_start. The webhook re-calls upsertSubscriptionwith the new value - Vevee updates the anchor and the next metered event creates a counter row in the new period. The old counter row is left untouched (analytics can still query it); only the “active” row advances.
  • Same-plan, same-cycleStart calls are full no-ops. No history rows. Safe to retry from idempotent webhook handlers.

What still drives the period length

cycleStart only changes the anchor - the period length still comes from plan.period_type (daily, weekly, monthly, lifetime). For monthly relative periods, Vevee uses a 30-day window from the anchor. Stripe's actual months vary between 28 and 31 days, so a single anchor can drift by a day or two across the year - but because the renewal webhook re-asserts cycleStartevery cycle, drift never accumulates. Vevee stays within a day of Stripe's clock for free.

Omit, set, clear

The cycleStart field has three valid shapes; pick whichever matches the call site:

What you passWhat happensUse when
(omitted)Existing anchor is preserved.Calling upsertSubscription from a code path that doesn't know the renewal date - signup, login middleware, plan-page upgrades.
ISO 8601 stringAnchor is set (or overwritten).Stripe webhook, manual billing-date adjustments, migrating a user from one billing provider to another.
nullAnchor is cleared. Subscription falls back to plan-level period_anchor.Moving a user off external billing (e.g. enterprise sponsorship, lifetime grant) where the plan's default anchor is now correct.

What does not happen on renewal

Renewal updates the anchor; nothing else changes. Specifically:

  • The events log is append-only - every event the user generated in the previous cycle stays queryable forever. Renewal does not delete or trim it.
  • The subscription_events history table writes no row on a same-plan upsert, even if cycleStart changed. Your timeline reflects real state transitions (created, plan_changed, canceled), not idempotent webhook deliveries.
  • Existing counter rows from the previous cycle are not deleted. They simply stop being read once period_start advances.

Reactivation

Same one method, both patterns. Whether the user was downgraded to free or canceled outright, you bring them back with a normal upsert:

await vevee.upsertSubscription({
  userId: 'user_abc123',
  planId: 'plan_pro_monthly',
});

That clears ends_at on the current-state row and appends a fresh plan_changedentry to the history. No special “reactivate” method - the timeline shows the full story regardless.

Subscription history at a glance

Both methods write to the same append-only audit table (subscription_events on the backend). A user who signs up free, upgrades, downgrades, cancels, and resubscribes produces five history rows - never overwritten - that you can render as a timeline in your admin UI or query for churn / cohort analytics.

event_typeWritten byWhen
createdupsertSubscriptionFirst time a user gets a plan on this app.
plan_changedupsertSubscriptionAny subsequent call with a different planId. Same-plan upserts write nothing.
canceledcancelSubscriptionEffective immediately or scheduled via endsAt.
i
A no-op upsertSubscription (same plan, no change) writes no history row. Only real state transitions are recorded - your timeline stays clean even if you call upsert on every login or every retried webhook.

Quick decision flow

User is churning →
  Does your app have a free plan?
    Yes → vevee.upsertSubscription({ userId, planId: 'free' })
          (with onPlanChange: 'block' on the free plan to prevent cycling)
    No  → vevee.cancelSubscription({ userId, endsAt?: 'period end' })
          (canUse / reserve start blocking once endsAt passes)

User is renewing (Stripe / Paddle / etc. webhook fires) →
  vevee.upsertSubscription({
    userId,
    planId,
    cycleStart: providerCurrentPeriodStart,   // ISO 8601
  })

User is coming back →
  vevee.upsertSubscription({ userId, planId })   // same in both worlds

Where to go next