End a user's subscription. Once endsAt is reached (immediately if you omit it), canUse and reserve start returning { allowed: false, matched: false, reasons: ['no_subscription'] } - the user is blocked from consuming any metered feature. track still records the attempt with match_status: 'no_subscription' but increments nothing and bills nothing.
cancelSubscription only when your product requires a paid subscription and a churned user should be hard-blocked. See the downgrade vs cancel guide.Signature
cancelSubscription(params: {
userId: string;
endsAt?: string; // ISO 8601. Omit to cancel immediately.
reason?: string; // Free-text label, stored on the history row.
}): Promise<CancelSubscriptionResponseData>Response
interface CancelSubscriptionResponseData {
subscriptionId: string; // 'sub_…'
userId: string;
planId: string; // The plan the user was on at cancel time.
endsAt: string; // ISO 8601 - when the cancellation takes effect.
}Examples
Cancel immediately
await vevee.cancelSubscription({
userId: 'user_abc123',
reason: 'user_cancel',
});
// canUse / reserve start returning allowed: false right away.Schedule cancellation at the end of the paid period
Common Stripe pattern - let the user keep using the plan until their current billing period ends, then block. Pass the period end 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',
});
}
}Reactivate a canceled user
Just call upsertSubscription with the plan you want them on. That clears ends_at on the current-state row and appends a fresh entry to the subscription history.
await vevee.upsertSubscription({
userId: 'user_abc123',
planId: 'plan_pro',
});How it affects metering
| Method | Before endsAt | After endsAt |
|---|---|---|
canUse() | Normal behavior - checks counters against quota. | { allowed: false, matched: false, reasons: ['no_subscription'] } |
reserve() | Normal behavior - holds quota, returns a reservationId. | { allowed: false, matched: false, reasons: ['no_subscription'] } (no reservation row created) |
track() | Normal behavior - increments counters. | Event row is recorded with match_status: 'no_subscription'; counters and cost are not touched. Lets the dashboard show the failed attempt. |
usage() | Returns the user's counters. | Throws subscription_not_found (HTTP 404). Catch it and show an upsell. |
Scheduled cancellation
When endsAt is in the future, the user keeps their current plan and limits until that moment. The subscription row stays active (loadSubscription ignores ends_atwhile it's still in the future). To change your mind before then, call cancelSubscription again with a different endsAt - or call upsertSubscription to clear it entirely.
Subscription history
Each call appends a row to the subscription_events audit log with event_type: 'canceled', the prior planId in from_plan_id, and your reason if provided. That log is append-only - call cancelSubscription as many times as you need, the timeline always reflects reality. See the core concepts page for the full lifecycle model.
Errors
not_found(404) - no subscription exists for thisuserIdon this app.already_canceled(409) - the existingends_athas already elapsed. (Scheduled cancellations with a futureends_atare still updatable.)invalid_request(400) - malformedendsAtorreasonover 500 chars.requires_secret_key(401) - you called it with apk_live_public key.
Where to go next
- Managing subscriptions: downgrade vs cancel - the decision guide for which method to use.
- upsertSubscription() reference.