Billing
app.billing exposes Stripe checkout, the customer portal, and the per-tenant entitlement
map. Plans are defined in the dashboard; each plan binds to a Stripe price and a bag of
features and limits that travel with every tenant on that plan.
Free
- ✓100 records
- ✓50 MB file storage
- ✓Community support
Pro
- ✓10k records
- ✓10 GB file storage
- ✓Email support
- ✓Custom domain
Team
- ✓Unlimited records
- ✓100 GB file storage
- ✓Priority support
- ✓SSO
The mental model
- A plan is configured in the dashboard with a Stripe price ID and a list of features (booleans) and limits (numbers).
- A tenant's subscription points at one plan.
getSubscription()returns it. - A tenant's entitlements are the flattened bag from their current plan plus any
overrides.
hasFeature(key)andgetLimit(key)are the two accessors you'll use 95% of the time. - Limits like
max_<collection>are enforced server-side at insert time — you don't need to check them manually to stay honest.
Configuring plans (dashboard)
Plans live in Dashboard → Plans for each app. They're per-app: every app you create defines its own pricing. There's no global plan registry.
One-time: connect Stripe
Visit Dashboard → Stripe and click Connect Stripe the first time. Pluralize uses
Stripe Connect — your customers pay your Stripe account directly, Pluralize takes a flat
5% application fee. Once connected, every new plan with priceCents > 0 gets a
Stripe Product + Price provisioned automatically. Free plans (priceCents: 0) skip
Stripe entirely.
You can ship without connecting Stripe at all if every plan you offer is free. Wire Stripe later when you're ready to charge.
Plan shape
| Field | Type | Notes |
| --- | --- | --- |
| name | string | Display name shown in pricing UIs. |
| slug | string | Lowercase identifier you'll see in session.subscription.planSlug. Immutable after creation. |
| description | string? | One-line marketing blurb. |
| priceCents | number | 0 for a free plan; otherwise the recurring amount in the smallest currency unit. |
| currency | eur|usd|gbp | Sets the Stripe Price currency. Cannot change after plan has subscribers. |
| interval | month|year | Billing cadence. |
| sortOrder | number | Controls order in getPlans() — lower first. Useful for "Free → Pro → Team" displays. |
| entitlements | array | See below. |
Entitlement types
Each entitlement is { featureKey, valueType, value }. Two value types:
boolean— feature on/off. The form coercesvalueto'true'or'false'. Read on the client withawait app.billing.hasFeature('export_csv').limit— a numeric cap (positive integer). Read withawait app.billing.getLimit('max_recipes'). Returnsnullwhen not configured — treat as unlimited.
The dashboard form auto-picks limit when a feature key starts with max_ or
limit_; everything else defaults to boolean. You can override the type per row.
Naming conventions
There's no enforced taxonomy — featureKey is opaque to Pluralize and meaningful only
to your app. Suggested patterns to keep your code readable:
| Pattern | Use |
| --- | --- |
| max_<collection> | Numeric cap on rows in a collection. Auto-enforced at insert: returns 402 limit_reached so you don't have to count yourself. |
| <feature>_enabled or has_<feature> | Boolean toggle. Gate UI with <FeatureGate> (see below). |
| <feature>_daily / <feature>_monthly | Numeric quota you reset and enforce yourself in your handlers (Pluralize doesn't track windowed counters). |
| max_tenant_invites | Reserved key. When > 0, this tenant can mint their own invite tokens via app.auth.mintInvite() up to the cap (lifetime). Useful for friends-and-family viral growth on paid tiers. |
Starting points
- MVP / pre-revenue: ship a single
freeplan withpriceCents: 0and no entitlements. The dashboard isn't empty, every tenant defaults onto it, and you can add paid tiers later without migration. - Two-tier SaaS:
free(caps viamax_*) +pro(unlimited or higher caps, feature flags viahas_*). SetsortOrder: 0/1so they list in the right order. - Usage-tracked AI: add a
<feature>_dailynumeric entitlement and reset a per-tenant counter in your own table at midnight — Pluralize hands you the cap, you decide when it resets.
Checkout
Send the user to Stripe Checkout. On success they return with an active subscription; their entitlements update immediately.
const { url } = await app.billing.createCheckout({
planId: 'plan_pro',
successUrl: 'https://example.com/welcome',
cancelUrl: 'https://example.com/pricing',
});
window.location.href = url;Customer portal
Stripe's hosted portal lets users update payment methods, cancel, or switch plans without you writing any UI:
const { url } = await app.billing.createPortal({
returnUrl: 'https://example.com/account',
});
window.location.href = url;Reading the current subscription
const sub = await app.billing.getSubscription();
if (sub?.status === 'active') {
// grant Pro features
}status is one of active, trialing, past_due, canceled, or incomplete.
Entitlements
Entitlements are the source of truth for what the current tenant can do. Two convenience accessors cover the common cases:
const canExport = await app.billing.hasFeature('export_csv'); // boolean
const maxRecipes = await app.billing.getLimit('max_recipes'); // number | nullgetLimit returns null when the tenant's plan has no such limit configured (interpret
as "unlimited").
max_<collection> limits are enforced at insert time: exceeding the cap returns
402 limit_reached with { feature, limit, current }.
Listing plans
const plans = await app.billing.getPlans();Returns the plans configured for the app, including prices and feature/limit bundles. Useful when you want to render your own pricing table instead of sending users straight to checkout.
Full example — pricing page + entitlement gate
CSV export is a Pro feature
Upgrade your plan to export your entire recipes collection in one click.
Pricing table
// app/pricing/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { usePluralize } from '@pluralize/sdk/react';
import type { PlanInfo, Subscription } from '@pluralize/sdk';
export default function PricingPage() {
const app = usePluralize();
const [plans, setPlans] = useState<PlanInfo[]>([]);
const [sub, setSub] = useState<Subscription | null>(null);
useEffect(() => {
app.billing.getPlans().then(setPlans);
app.billing.getSubscription().then(setSub);
}, [app]);
async function onSubscribe(planId: string) {
const { url } = await app.billing.createCheckout({
planId,
successUrl: `${window.location.origin}/welcome`,
cancelUrl: `${window.location.origin}/pricing`,
});
window.location.href = url;
}
async function onManage() {
const { url } = await app.billing.createPortal({
returnUrl: `${window.location.origin}/account`,
});
window.location.href = url;
}
return (
<main className="mx-auto grid max-w-4xl gap-4 p-6 sm:grid-cols-3">
{plans.map((p) => {
const current = sub?.planId === p.id;
return (
<div key={p.id} className="rounded-xl border p-5">
<h3 className="text-sm font-semibold">{p.name}</h3>
<p className="mt-2 text-2xl font-semibold">${p.price / 100}</p>
<ul className="mt-4 space-y-1 text-sm">
{p.features.map((f) => (
<li key={f}>• {f}</li>
))}
</ul>
{current ? (
<button
onClick={onManage}
className="mt-5 w-full rounded-md border py-2 text-sm"
>
Manage subscription
</button>
) : (
<button
onClick={() => onSubscribe(p.id)}
className="mt-5 w-full rounded-md bg-black py-2 text-sm text-white"
>
Subscribe
</button>
)}
</div>
);
})}
</main>
);
}Gate a feature behind an entitlement
// components/feature-gate.tsx
'use client';
import { useEffect, useState } from 'react';
import { usePluralize } from '@pluralize/sdk/react';
export function FeatureGate({
feature,
fallback,
children,
}: {
feature: string;
fallback: React.ReactNode;
children: React.ReactNode;
}) {
const app = usePluralize();
const [allowed, setAllowed] = useState<boolean | null>(null);
useEffect(() => {
app.billing.hasFeature(feature).then(setAllowed);
}, [app, feature]);
if (allowed === null) return null; // or a skeleton
return allowed ? <>{children}</> : <>{fallback}</>;
}Use it to wrap any UI you want to hide behind a paid plan:
<FeatureGate
feature="export_csv"
fallback={<UpgradeBanner target="Pro" />}
>
<ExportButton />
</FeatureGate>Webhooks
Pluralize receives Stripe events for you. You do not need to wire a webhook endpoint in
your own app — the dashboard's webhook is the source of truth and updates the
subscription record used by getSubscription(). If you need to react to plan changes
in your UI, poll getSubscription() on login and after checkout success, or listen to
app.auth.onChange and refetch.
Error reference
| Status | code | Cause |
| --- | --- | --- |
| 400 | invalid_plan | planId does not exist on this app. |
| 402 | limit_reached | Tenant hit a numeric limit at insert time. |
| 409 | already_subscribed | Tenant already has an active subscription to that plan. |