How to cancel a subscription without burning the bridge (or your data)
The user cancels. Do they drop to a free tier, or lose access when the paid month runs out? Those are different products, different SDK calls, and different mistakes when you get them wrong.
Cancellation is a fork, not a delete
The instinct when a user cancels is subtractive: delete the subscription row, zero the counters, clean up. Every part of that is wrong. Deleting the subscription destroys the history you need for churn analysis and the dispute that arrives in 60 days. Zeroing counters erases consumption that already happened. And "cleanup" usually runs from a billing webhook that will, someday, be delivered twice. Cancellation done right is additive - a state transition appended to history - and the only real decision is what the user’s access becomes afterward. There are exactly two good answers.
Pattern one: cancel means downgrade
If your app has a free tier, cancellation is not an ending - it is a plan change to free. One call, the same upsert you used at signup. The subscription stays alive, a plan_changed transition lands in history, and the user keeps using the product under free limits. This is the pattern that preserves the relationship: the user stays in the product, keeps their data and their habits, and your win-back motion is an upgrade nudge instead of a re-acquisition campaign.
// Billing webhook: subscription cancelled -> drop to free
if (event.type === 'subscription.canceled') {
await vevee.upsertSubscription({
userId: event.userId,
planId: 'free', // logs plan_changed; user keeps free-tier access
});
}Pattern two: cancel means a scheduled stop
No free tier? Then cancellation means access ends - but almost never immediately, because the user paid through the end of the period. cancelSubscription with endsAt set to the period end gives you the standard SaaS behavior with no scheduler: the user keeps full access until the timestamp passes, and then canUse and reserve start returning no_subscription on their own. Nothing fires at midnight, nothing can fail to fire - the expiry is a property of the subscription, evaluated at request time.
// Paid-only app: access survives until the period the user paid for ends
await vevee.cancelSubscription({
userId: event.userId,
endsAt: new Date(event.currentPeriodEndUnix * 1000).toISOString(),
});
// After endsAt passes - no cron involved:
// canUse / reserve -> { allowed: false, reasons: ['no_subscription'] }What happens to the events of the departed
A detail most metering systems fumble: the user whose access ended may still trigger calls - a stale client, a queued job, an API integration they forgot. Vevee’s track still records those events, marked no_subscription, with zero counter increments and zero cost attribution. Access enforcement and historical record stay separate concerns: the user is blocked where blocking matters, but your analytics never develops a blind spot, and when they reactivate - one upsertSubscription call, logged as reactivated - their full timeline is intact on both sides of the gap.
Pick one pattern per app. Seriously.
The patterns are both correct and mutually exclusive. Mixing them - downgrading some cancels to free while scheduling stops for others, in the same app - produces a subscription history that no analyst can read: was this user churned or free? Did that cohort lapse or downgrade? Every downstream metric forks on a distinction your own data cannot make. The rule is mechanical: free tier exists, cancel is upsert to free; no free tier, cancel is cancelSubscription with endsAt. Decide once, write it in the runbook, and your churn dashboard stays a dashboard instead of a forensic investigation.
Keep reading
The race condition in "if (usage < limit)" that is costing your AI app money
A user at nine of ten images opens six tabs and clicks generate in all of them. Every tab passes your limit check, every tab gets an image, and you pay for all six. The bug is one read-then-write - and your unit tests will never catch it.
MeteringHow to build a credits system for your AI app (ledger design, rollover, refunds)
A user emails: "I had 40 credits this morning and now I have 12, and I only made one image." If your balance is a column you mutated, you cannot answer that email. Here is the ledger design that can.