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.
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 renewal does (and what stays put)
On a same-plan renewal with a new cycleStart, Vevee writes exactly one row to subscription_events with event_type = renewed. That gives you renewal counts, MRR-style timelines, and churn analytics straight out of the audit table - no extra plumbing. Retried webhooks that carry the same cycleStart stay no-ops, so the count reflects real billing cycles, not delivery flakiness. Lifetime plans skip the renewed event entirely. What does not change: the events log is append-only and never trimmed, so every prompt and render the user ever generated remains queryable forever; and counter rows from the previous cycle aren't deleted, they're simply no longer the active row once period_start advances.
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.
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.