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.
Last updated: 2026-05-14
Two clocks, one quota
The naive integration has Stripe owning billing dates and Vevee owning quota cycles, and nobody telling them about each other. Stripe charges the user on Jan 15, Feb 15, Mar 15. Vevee resets their counters on Feb 1, Mar 1, Apr 1. On the 1st the user sees a fresh quota two weeks before their next charge - over-served. On the 15th they renew and nothing changes in the dashboard - they keep ticking down toward the next reset on the 1st. The drift is invisible until a power user emails support asking why they got more than they paid for, or why they ran out a week early.
Why a "reset on renewal" function is the wrong shape
The obvious instinct is to add a resetCycle endpoint and call it from the Stripe webhook. Don't. Two problems. One: it requires you to think of cycles as discrete events, but Stripe webhooks aren't reliable enough for that - a missed delivery means a user is stuck in last month's window forever. Two: it tempts you to delete or zero-out counters, which makes analytics useless ("how many images did this user generate last cycle?" becomes unanswerable).
The right shape: a per-subscription anchor
Vevee now stores an optional cycle_anchor_at per subscription. When set, it's used as the relative-period anchor instead of started_at or the plan-level calendar default. Pass it as cycleStart on upsertSubscription. That's the whole API surface - no new endpoint, no special path, no migration. Counter rows are keyed on (user, group, period_start); when the anchor advances, the next metered event computes a new period_start and inserts a fresh counter row. Old rows stay queryable. Events are append-only and untouched.
The Stripe webhook, in full
You wire it once. customer.subscription.created and customer.subscription.updated both carry current_period_start; handling them identically makes the integration idempotent for free. Each call passes the subscription ID, the plan id derived from the price id, and cycleStart set to new Date(sub.current_period_start * 1000).toISOString(). Re-delivered webhooks re-assert the same anchor - no double-reset bug. Stripe pauses, prorations, and coupons all flow through because you're reading the canonical period_start from the source of truth.
What does not happen on renewal
Three things stay still on a same-plan renewal. The events log is append-only and never trimmed - every prompt, every render, every token your users ever consumed remains queryable forever, exactly what you need for debugging and audits. The subscription_events history table writes no row, because nothing actually transitioned. And counter rows from the previous cycle aren't deleted; they're just no longer the active row. Your churn dashboard, your usage trends, your "what did user_xyz do last month" forensic queries all keep working.
Three call shapes, three intents
cycleStart accepts three values. An ISO string sets the anchor - call this from your Stripe webhook. Omitting the field preserves whatever anchor is currently stored - call this from signup, login middleware, or any code path that doesn't know the renewal date and shouldn't guess. Passing null explicitly clears the anchor - call this when migrating a user off external billing, e.g. converting them to a comp plan or an enterprise sponsorship where the plan's default anchor takes over again. The three shapes cover every realistic integration pattern without growing the API surface.
The 30-day footnote
One detail to flag: for relative monthly periods, Vevee uses a 30-day window from the anchor. Stripe's real months vary from 28 to 31 days. If you set the anchor once and never touch it again, the two clocks would drift by up to three days per year. The reason this doesn't matter in practice is that your renewal webhook re-asserts cycleStart every cycle - Stripe's exact current_period_start, which already accounts for real calendar months. Drift never accumulates because the anchor moves with Stripe.
Where this leaves you
You keep Stripe as the source of truth for billing. You keep Vevee as the source of truth for usage. One field on one method call connects them. Your dashboard's "remaining this cycle" number now matches your customer's expectations, your charts still answer historical questions, and you never need to write - or maintain - a counter-reset path of your own. That's the entire point of a metering layer: it should track the clocks you already pay for, not invent new ones.
More from the blog
Stop 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.