Assign a plan to an end-user, or move them between plans. Idempotent - call it on signup, on payment success, on upgrade, on downgrade. Always reflects the user's current plan.
Signature
upsertSubscription(params: {
userId: string;
planId: string; // 'plan_…' from your dashboard
customLimits?: PlanLimits; // optional overrides for this specific user
endsAt?: string; // optional ISO 8601 expiry
cycleStart?: string | null; // align renewal cycle to an external billing provider
}): Promise<UpsertSubscriptionResponseData>Response
interface UpsertSubscriptionResponseData {
subscriptionId: string; // 'sub_…'
userId: string;
planId: string;
startedAt: string; // ISO 8601
cycleAnchorAt: string | null; // effective renewal anchor (null = plan-level)
}Examples
On signup - assign the free plan
await vevee.upsertSubscription({
userId: newUser.id,
planId: 'plan_free',
});On Stripe webhook - upgrade to pro
// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
const event = stripe.webhooks.constructEvent(/* … */);
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
await vevee.upsertSubscription({
userId: session.client_reference_id!,
planId: 'plan_pro',
endsAt: new Date(session.expires_at * 1000).toISOString(),
});
}
return new Response('ok');
}Per-user override
// Grant an enterprise customer 10x the normal pro quota
await vevee.upsertSubscription({
userId: 'user_acme_inc',
planId: 'plan_pro',
customLimits: {
period: 'monthly',
anchor: 'subscription_start',
groups: [
{
id: 'lg_images',
name: 'Images',
unit: 'count',
quota: 5000,
match: [{ event: 'image.render' }],
},
],
},
});Aligning renewals with Stripe (or any external billing provider)
By default, a plan's period_anchordrives when its counters reset - either on a calendar boundary or relative to the user's started_at. That works when Vevee is the only thing tracking renewals. When Stripe (or Paddle, or LemonSqueezy, or your own billing system) owns the real renewal date, those two clocks drift: a user can hit a “reset” weeks before their next charge, or vice versa.
Pass cycleStartto pin a subscription's renewal anchor to whatever timestamp your billing provider says is the start of the current cycle. The next track / canUse / reserve computes the new period from that anchor and lazily allocates fresh counter rows - no migration, no destructive reset. Events and history are untouched.
// app/api/webhooks/stripe/route.ts
if (event.type === 'customer.subscription.updated' ||
event.type === 'customer.subscription.created') {
const sub = event.data.object;
await vevee.upsertSubscription({
userId: sub.metadata.app_user_id,
planId: planFromStripePrice(sub.items.data[0].price.id),
cycleStart: new Date(sub.current_period_start * 1000).toISOString(),
});
}Idempotency on cycleStart follows the same rule as the rest of the call:
- Omit it - any previously-set anchor is preserved. Safe to call from code paths that don't know the renewal date.
- Pass an ISO string - sets (or overwrites) the anchor. Re-passing the same value is a no-op.
- Pass
null- clears the anchor; the subscription falls back to the plan-levelperiod_anchor.
Setting cycleStart implicitly switches that subscription to subscription_start semantics even if the plan is configured with period_anchor = 'calendar' - different users on the same plan can have different cycle starts. For the full walkthrough, see Aligning renewals with Stripe in the subscriptions guide.
Same-plan upsert is a no-op
Calling upsertSubscription with the same planId the user is already on does not reset anything. Counters keep ticking, the existing startedAt is preserved, and the active period is not recomputed. Safe to call on every login, on every retried Stripe webhook, in onAuth middleware - call it as often as you want.
Plan-change semantics
When planIdactually changes mid-period, each limit group on the new plan picks one of three behaviors. You configure this once per plan in the dashboard's Advancedsection at the bottom of the plan form. The SDK call doesn't take a parameter for it - the plan owns the policy.
| Mode | What happens at the switch | Use when |
|---|---|---|
carry (default) | Existing counters with the same limit-group ID continue. New groups start at 0. | Generous UX. Upgrades feel free. |
reset | Counters for the new plan's groups are wiped - the user starts the period at 0. | Plan change should mean a clean slate (e.g. moving across product tiers). |
block | Counters are pre-filled to quota; canUse / reserve return limit_reached until next period rollover. | Anti-abuse. Closes the “free → upgrade → cancel → fresh free quota” cycling exploit. |
Cancellation
How you handle cancellation depends on whether your app has a free plan:
- Has a free plan - call
upsertSubscriptionwith the free plan's id. The user keeps using your app under free limits and the history recordsplan_changed. - No free plan - call cancelSubscription(). After
endsAt,canUse/reservereturnno_subscriptionand the user is blocked from metered features.
The full decision guide is in Downgrade vs cancel.
Errors
not_found(404) -planIddoes not belong to this app.invalid_request(400) - malformedcustomLimitsor invalidendsAt.
Getting the plan id
You can hardcode planId: 'plan_xxx', but if your pricing page or billing flow already knows which plan the user picked, fetch it dynamically with vevee.availablePlans() - the returned id on each plan is exactly what upsertSubscription expects.