How to reset usage limits when a subscription renews
A weekly plan, used twice. First week: ten generations. Second week: zero, because the counter never reset. This is the cron-job mistake - and the fix is one field on one call.
Last updated: 2026-05-30
The counter is doing more work than you think
A "weekly" plan is not actually weekly inside your database. It is a counter row keyed on (user, limit_group, period_start) that accumulates until period_start changes. When it changes, the next event allocates a fresh row and the old one becomes history. Nothing scheduled, nothing destructive. The hard question is who decides when period_start changes - and the right answer is the same system that owns the billing date.
Why a "reset" endpoint is a trap
The instinct is to add a resetUsage(userId) method, wire it to a cron job that runs Monday at midnight UTC, and forget about it. Two ways that breaks. One: the cron is global, so every user on a weekly plan resets at the same instant - regardless of when they actually signed up or paid. A user who bought on Friday gets two days, then a reset. Two: when you add a second plan with a different period, you add a second cron - and now your scheduler is a load-bearing piece of pricing infrastructure that has to stay synchronized with whatever the dashboard claims the plans look like.
The renewal call is the reset
In Vevee, upsertSubscription with the same planId and a new cycleStart is the renewal. It writes a renewed row to subscription_events, advances the per-subscription anchor, and lets the next metered event lazily allocate a fresh counter at the new period_start. One call covers three jobs: marking the renewal in your audit log, defining when the new quota starts, and resetting the counter without deleting anything. You do not need a cron, you do not need a separate "reset" endpoint, and you do not need to wonder whether the cron fired.
A weekly plan, in seven lines
Whatever owns the billing date - Stripe, RevenueCat, your in-house subscriptions table - fires a webhook when the next cycle starts. From inside that handler you call upsertSubscription with the user id, the plan id, cycleStart set to the new period start as ISO 8601, and endsAt set to the same value plus seven days. Both fields are idempotent: the same webhook delivered twice updates nothing the second time, and writes no extra renewed row. The user gets a fresh weekly quota and your churn dashboard gets a renewal event, off one call.
// app/api/webhooks/billing/route.ts
if (event.type === 'subscription.renewed') {
const cycleStart = new Date(event.cycleStartUnix * 1000).toISOString();
const endsAt = new Date(event.cycleStartUnix * 1000 + 7 * 24 * 3600 * 1000).toISOString();
await vevee.upsertSubscription({
userId: event.userId,
planId: 'plan_weekly_pro',
cycleStart, // resets the meter
endsAt, // auto-locks the user if next renewal never fires
});
}The auto-shutoff is part of the contract
Pass endsAt and the subscription becomes a self-destruct: once that timestamp passes, canUse and reserve return no_subscription and the user is blocked from metered features without you running anything. That matters because it means a missed renewal webhook does not silently extend the user's access - they hit a wall on the day they were supposed to be charged, which is exactly the behavior you want. The webhook handler's only job is to push endsAt forward; if it fails, the user is paused, not free-riding.
Same shape, every period length
Daily, weekly, monthly, even custom 17-day cycles - the upsert call is the same. The plan owns the period length and the period anchor; the renewal webhook owns the cycle start. Different plans on the same app can renew on different days of the week without any extra scaffolding, because every subscription carries its own anchor. The cron-job approach forces every user onto the same global rhythm; the per-subscription anchor lets each user have a personal one without you writing any of the bookkeeping.
Idempotency, written into the rule
Three call shapes, three meanings. Same planId and same cycleStart: full no-op, safe to retry. Same planId and new cycleStart: renewal, writes one renewed row, advances the anchor. Different planId: plan change, runs the new plan's onPlanChange policy (carry, reset, or block per limit group). One method, one mental model, zero special-case endpoints. Your billing webhook never has to know whether the user is renewing, upgrading, or first-paying - it sends what it knows and the metering layer figures it out.
What you keep, what you stop maintaining
You keep your billing provider as the source of truth for when the cycle starts - whatever Stripe or RevenueCat says, you forward. You stop maintaining a counter-reset cron, a global midnight job, a "period rollover" code path, and the runbook for what to do when the cron misses a day. Renewal becomes a property of the upsert, not a job that fires somewhere else. Your audit table gains a renewed row count you can chart against MRR, and your users get a quota that resets on the day they actually paid.
More from the blog
How to manage subscription renewals: aligning Vevee with Stripe
A user signs up on Jan 15. Stripe charges them on the 15th of every month. Your metering layer resets on the 1st. Two clocks. One angry support ticket per cycle.
engineering · 5 minStop hardcoding your pricing page - render it from your metering layer
Every B2B SaaS I have shipped repeats the same mistake: plans live in two places - the dashboard that enforces them, and a const PLANS = [...] on the marketing site. They drift within a quarter.
thinking · 4 minMeter AI by user, not by account - your margin depends on it
A few users will cost you 100x what your median user costs. If you only meter at the account level, you will not see them coming until your gross margin is gone.
engineering · 5 minreserve / commit / release: the only correct way to enforce AI quotas
Every team I have seen build per-user AI metering has shipped a version of canUse → call OpenAI → track. It looks correct in single-threaded tests. It is broken in production.
thinking · 4 minWhy Stripe Billing is not enough for AI products
Stripe is excellent at one thing: turning usage into invoices. AI products need three other things, and Stripe does not do any of them.