Plans, pricing and free tiers
What a plan is in Connect, why a monthly and an annual price are two rows, and what happens when a customer changes tier or cadence.
A workspace subscribes to one plan of your application at a time. That subscription is what every entitlement check resolves through, what every invoice line points at, and what a cancellation ends.
A plan is a tier and a price point
This is the first thing that surprises people arriving from Stripe, Paddle or Chargebee. All three split a product in two — a Product, and one or more Prices attached to it. Connect keeps them in one row:
Growth (monthly) ₹2,000 / month planGroup: "growth"
Growth (annual) ₹20,000 / year planGroup: "growth"Two Plan rows. Subscription.planId, every invoice line and every
entitlement check point at exactly one of them, which is what makes those
lookups a single read with no join.
planGroup is what recovers the missing half. Two plans declaring the same
group are the same tier at two cadences, and
(applicationId, planGroup, billingInterval) is unique among active plans — so
a tier can have at most one monthly and one annual row.
Do not pair plans by name. It is not unique, it does not survive a rename,
and cloning a plan produces "Growth (Copy)". planGroup is the only correct
pairing key. Rows written before the field existed fall back to a slug of
name, so nothing needed a backfill — but new code should never rely on that
fallback.
Reading plans
import { ConnectClient } from "@cosmoops/connect-sdk/server";
const connect = new ConnectClient({ apiKey: process.env.CONNECT_API_KEY! });
const plans = await connect.plans.list();The client components do this for you — <Plans /> renders the grouped tiers
with a monthly/annual toggle, and the toggle is driven by planGroup rather
than by a naming convention.
Free plans
A free plan is a plan whose price is zero. There is no separate concept and no
flag, and that is deliberate: a free tier subscribes, appears in
subscriptions, carries features, and shows up in entitlement checks exactly
like a paid one. Code that gates on a feature key needs no special case, and a
customer moving from free to paid is an ordinary upgrade rather than a first
subscription.
Two consequences worth designing around:
- A free plan still needs its features declared. An empty
featuresarray unlocks nothing. "Free" is a price, not a permission set. - A free plan changes what onboarding should ask for. With no free tier,
every customer must reach a payment method before they can use anything, so
the payment step is worth making
required. With one, demanding a card up front loses sign-ups for nothing. The Console's application wizard makes exactly this recommendation, for exactly this reason.
Free plans versus trials
They are different mechanisms and they solve different problems.
| Free plan | Trial | |
|---|---|---|
| Price | ₹0, indefinitely | The plan's real price, deferred |
| Ends | Never | After trialDays |
| Needs a payment method | No | Yes — the first charge lands when it ends |
| Modelled as | A plan | A field on a plan |
Use a trial to let somebody evaluate a paid tier. Use a free plan when the free thing is a product you intend to keep running.
Changing plan
There are three kinds of change, not two, and the third is the one integrations forget:
- Upgrade — a higher-priced plan in the same cadence. Prorated and charged immediately.
- Downgrade — a lower-priced plan in the same cadence. The prorated difference is credited, refunded, or dropped, according to the application's downgrade policy.
- Cadence change — the same tier, the other cadence. Neither of the above.
classifyPlanChange returns switch-to-annual / switch-to-monthly for the
third, and it routes to its own path: the running cycle is closed, what is left
of it is valued, a fresh cycle is billed, and the billing anchor moves.
Sending a cadence change through the upgrade or downgrade path prorates a delta across two different cadences, which is negative either way, floors to zero, and settles the whole switch for nothing while leaving the old cycle boundary in place. If you are computing "is this an upgrade" yourself by comparing prices, you have this bug.
Surplus from cutting a long annual cycle short is always credited, never refunded, whatever the downgrade policy says. The customer keeps the same product, so there is nothing to compensate — only a balance to carry.
Limits on a plan
A plan gives each of your application's declared limits a number: none, a ceiling, or unlimited. This is how one tier allows a single site and the next allows five.
if (!(await connect.entitlements.canAdd(workspaceId, "sites", siteCount))) {
return refuse("Your plan's site limit is reached.");
}The catalogue read carries the labels too, so a pricing table can render "1 site" beside "5 sites" without hardcoding either — including the tiers that allow none, which is what makes the comparison legible. See Capabilities for the three states and the one way to get the comparison wrong.
Add-ons on a plan
A plan decides whether to offer an add-on and at what price and included quantity. It does not decide how the add-on behaves — see Capabilities.
What to build against
- Gate features on the feature key, resolved through the subscription, never on the plan's name or id. Names change; a customer on a legacy tier still has the entitlement.
- Read quantities from the limit key, the same way, and never infer one from
the tier's name. Encoding a number as three mutually-exclusive feature keys
(
sites_1,sites_5,sites_unlimited) is the shape limits replace: no card can compare them and no backend can read them as a quantity. - Treat the plan id as opaque. It differs per environment.
- Re-read readiness rather than caching a subscription indefinitely — the pull channel is authoritative, webhooks are the fast channel.
Concepts
The four models you have to share with Connect — plans, capabilities, authorization and deletion — and where the boundary between us runs.
Capabilities — features, limits and add-ons
What a plan unlocks, how much of it a tier allows, what a customer can buy on top, and why behaviour lives on the application while price lives on the plan.