vevee.upsertSubscription()POST /api/v1-12/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 | null;            // ISO 8601 expiry. SDK 1.13+: omit keeps it, null clears it
  cycleStart?: string | null;        // align renewal cycle to an external billing provider
  provider?: string;                 // 'revenuecat', 'polar', 'stripe', …
  externalId?: string;               // the provider's subscription id
  onlyIfNone?: boolean;              // SDK 1.11+: create only, never change an existing one
}): 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)
  // Nothing was changed; the fields above describe the current subscription.
  ignored?: 'stale_subscription' | 'billed_subscription' | 'has_subscription';
}

Examples

On signup - assign the free plan

await vevee.upsertSubscription({
  userId: newUser.id,
  planId: 'plan_free',
  onlyIfNone: true, // never touches a subscription the user already has
});

With onlyIfNone: true (SDK 1.11+) the subscription is created only when the user has no active one; otherwise nothing changes and the response has ignored: 'has_subscription'. The check and the write happen in one transaction, so you never need to read usage first.

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',
      provider: 'stripe',
      externalId: session.subscription as string,
    });
  }

  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.

Since SDK 1.13, monthly periods step by calendar month from the anchor: an anchor on the 15th resets on the 15th of every month, and one on the 31st resets on the last day of shorter months. Subscriptions last written by an older SDK keep fixed 30-day steps until their next write through 1.13, which moves the current period's usage onto the new period so nobody gets a fresh quota from the switch.

Ending a subscription with endsAt

endsAt is when the subscription ends. Once it passes the user has no subscription (canUse answers no_subscription) until the next upsert. It does not reset limits; send cycleStart on each renewal for that. Since SDK 1.13 it follows the same rule as cycleStart:

  • Omit it: the stored end date is kept, so a call made on every login does not clear one your billing webhook set. A write that starts a new subscription (the old one had ended, or a new externalId takes it over) clears it.
  • Pass an ISO string: sets (or overwrites) it.
  • Pass null: clears it, e.g. when a canceled subscription is resumed under the same externalId.

SDK 1.12 and earlier clear the end date whenever the field is left out.

Idempotency and renewals

The same method covers first purchase, retried webhooks, renewals, plan changes, and reactivation. What it actually does is decided by what changed since the last call:

What changedHistory eventEffect on counters
First call for this user.createdFresh counters allocated lazily on the next track.
Same planId, same cycleStart (or omitted).none (no-op)Counters keep ticking. Safe to call on every login or retried webhook.
Same planId, new cycleStart.renewedNext track rotates period_start → fresh counter row. Previous counter stays as history.
Different planId.plan_changedEach new-plan limit group applies its onPlanChange policy (carry / reset / block). Groups are paired across plans by theirkey: same key on both plans means the counter moves with the user. A group without a key, or without a matching key on the old plan, starts from zero.

Keys pair the old and new plan's counters at the moment of the change. A reservation that is still pending at that moment keeps the old group id: a later commit lands correctly, but a release or expiry after the switch cannot give the reserved amount back. Finish or release in-flight reservations before switching plans if that matters to you.

Lifetime plans skip renewed detection - computePeriod ignores the anchor for lifetime periods, so advancing cycleStart on a free / lifetime plan is a quiet no-op. Calling upsertSubscription({ planId: 'free' }) once a request will never produce duplicate history rows.

This is the rule that lets a single endpoint cover what other systems split into “purchase” and “renew”: Stripe, RevenueCat and Paddle all deliver new-cycle events with a fresh current_period_start; passing that as cycleStart from any of them produces a renewed row without your having to think about which webhook is which.

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.

A subscription another user holds (SDK 1.10+)

App Store and Play Store subscriptions belong to the store account. When an externalId you pass is already held by another of your users, the store moved the subscription (a restore or a login on another account). Since SDK 1.10 the call transfers that subscription to userId with the units already used this period, and the response has transferred: true, instead of starting a fresh subscription at zero. A RENEWAL that reaches the new owner before the TRANSFER is therefore safe. See transferSubscription().

SDK 1.9 and earlier call POST /api/v1/subscriptions, which keeps the old behavior (a new subscription for the caller).

Billed subscriptions only answer to their own id (SDK 1.11+)

A subscription registered with both provider and externalId is billed: the billing provider owns it. Since SDK 1.11 only a call naming that externalId can change or cancel it. A call without it (or naming it under a different provider) changes nothing and resolves with ignored: 'billed_subscription'; the response describes the current subscription. This stops a “register free” call that raced a store webhook from downgrading a paying customer.

Still allowed: a call naming the right externalId; a paid upsert with a new externalId on a user whose only subscription is unbilled (it replaces the Free row); a new externalId from the same provider once the billed subscription has ended or was canceled with cancelSubscription (a resubscribe); and an externalId held by another user (a transfer). Any other new externalId is recorded as a separate subscription and reported in duplicateActiveSubscriptions, so the original id keeps receiving its renewals. Subscriptions registered without an externalId are not billed and behave as before. SDK 1.10 and earlier keep the old behavior.

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. For a billed subscription, pass the same provider and externalId from the expiration webhook.
  • 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.