vevee.upsertSubscription()POST /api/v1/subscriptionssk_live_

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-level period_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.

ModeWhat happens at the switchUse when
carry (default)Existing counters with the same limit-group ID continue. New groups start at 0.Generous UX. Upgrades feel free.
resetCounters 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).
blockCounters 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 records plan_changed.
  • No free plan - call cancelSubscription(). After endsAt, canUse / reserve return no_subscription and the user is blocked from metered features.

The full decision guide is in Downgrade vs cancel.

Errors

  • not_found (404) - planId does not belong to this app.
  • invalid_request (400) - malformed customLimits or invalid endsAt.

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.