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
| Method | What it does | Use 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
onPlanChangemode 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_atis set to now. The current-state row stays - itsplan_idis unchanged - but it's flagged as ended.canUse()andreserve()return{ allowed: false, matched: false, reasons: ['no_subscription'] }. Your existingif (!result.allowed) return ...guard handles the block - no client-side change needed beyond a “subscribe to continue” UI.track()still records the event withmatch_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 yourreason.
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,
cycleStartis 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 thatperiod_start. - On every subsequent renewal, Stripe fires
customer.subscription.updatedwith a newcurrent_period_start. The webhook re-callsupsertSubscriptionwith 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-
cycleStartcalls 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 pass | What happens | Use 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 string | Anchor is set (or overwritten). | Stripe webhook, manual billing-date adjustments, migrating a user from one billing provider to another. |
null | Anchor 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
eventslog is append-only - every event the user generated in the previous cycle stays queryable forever. Renewal does not delete or trim it. - The
subscription_eventshistory table writes no row on a same-plan upsert, even ifcycleStartchanged. 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_startadvances.
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_type | Written by | When |
|---|---|---|
created | upsertSubscription | First time a user gets a plan on this app. |
plan_changed | upsertSubscription | Any subsequent call with a different planId. Same-plan upserts write nothing. |
canceled | cancelSubscription | Effective immediately or scheduled via endsAt. |
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 worldsWhere to go next
- upsertSubscription() reference.
- cancelSubscription() reference.
- canUse() / can() - the method that returns
no_subscriptiononce a user is canceled. - Core concepts - for the full
onPlanChangesemantics (carry / reset / block).