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, and only pairs with the paid plan's counters when both groups share akey- 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 (and does not) happen on renewal
Renewal advances the anchor and writes one history row. 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 arenewedrow on every same-plan upsert wherecycleStartadvanced to a new, non-null value. Re-delivered webhooks that carry the samecycleStartstay no-ops, so your timeline counts real renewals, not retries. Lifetime plans skiprenewedentirely. - Existing counter rows from the previous cycle are not deleted. They simply stop being read once
period_startadvances.
Provider ids, duplicates and merges
Pass externalId (strongly recommended) and provider on upsertSubscription and cancelSubscription. The externalId is the id of the subscription at your billing provider (for example a RevenueCat original_transaction_id or a Polar subscription id), and provider names it (apple, stripe, ...). They let Vevee tell two subscriptions of the same person apart.
await vevee.upsertSubscription({
userId: 'user_abc123',
planId: 'plan_pro_monthly',
provider: 'apple',
externalId: originalTransactionId,
});Duplicates after a merge
When two ids are merged with customers.alias and both had a subscription, both rows are kept. One is the primary (the best active one by plan rank, then newest) and grants entitlement; the others are duplicates. Read them server-side with the secret key through duplicateActiveSubscriptions to ask the user which one to cancel.
Automatic promotion
If the primary ends (canceled, expired, refunded) and a duplicate is still active, the duplicate becomes the primary automatically and usage carries over, so the user is never blocked while they still pay for something.
ambiguous_subscription
Calls are routed to the right row by externalId first, then provider. If a user has two or more active subscriptions and a call carries neither, or still matches more than one row, it fails with ambiguous_subscription (409) instead of guessing. Send externalId on every subscription write once users can have more than one.
A new externalId
Always pass both provider and externalId. A call whose externalId matches no row is routed like this:
- One active subscription (of that
provider, when you pass one): the call updates it and replaces its storedexternalIdandproviderwith the new ones. This is the resubscribe case (Pro, downgraded, Pro again under a new provider id): the user keeps one row. - Two or more active subscriptions: the new
externalIdis a new subscription and gets its own row (unless exactly one of them has no storedexternalIdyet, which then adopts it).
Late events for a replaced subscription
Billing providers deliver events late and out of order. When a resubscribe replaces sub_1 with sub_2, Vevee remembers sub_1 on that row. A later upsert or cancel naming sub_1(for example the period-end “ended” event of the old subscription) changes nothing and succeeds with ignored: 'stale_subscription' in the response, so the user keeps the plan they are paying for and the provider stops retrying the event. A cancel naming an externalId Vevee has never seen still fails with not_found.
Guest registration order
When a guest signs up, call customers.alias. If the guest and the account both hold a default subscription (no provider, no externalId, such as the Free plan every account gets), the merge keeps one and ends the other, so there is nothing to clean up. Only paid subscriptions are ever reported as duplicates. A webhook upsert on the alias id that races the merge can still create a duplicate; the background sweep folds it in and reports it in duplicateActiveSubscriptions.
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. |
renewed | upsertSubscription | Same planId, but cycleStart advanced to a new value. Skipped on lifetime plans. |
canceled | cancelSubscription | Effective immediately or scheduled via endsAt. |
upsertSubscription (same plan, same cycleStart) writes no history row. Renewals (same plan, advanced cycleStart) write a renewed row. Login middleware and retried webhooks stay quiet; real billing cycles show up.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).