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.

yourapp.com/pricing

Free

$0forever
  • 100 records
  • 50 MB file storage
  • Community support
Current plan
Popular

Pro

$19per month
  • 10k records
  • 10 GB file storage
  • Email support
  • Custom domain
Upgrade

Team

$49per month
  • Unlimited records
  • 100 GB file storage
  • Priority support
  • SSO
Upgrade

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) and getLimit(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 coerces value to 'true' or 'false'. Read on the client with await app.billing.hasFeature('export_csv').
  • limit — a numeric cap (positive integer). Read with await app.billing.getLimit('max_recipes'). Returns null when 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 free plan with priceCents: 0 and 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 via max_*) + pro (unlimited or higher caps, feature flags via has_*). Set sortOrder: 0/1 so they list in the right order.
  • Usage-tracked AI: add a <feature>_daily numeric 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 | null

getLimit 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

yourapp.com/export
🔒

CSV export is a Pro feature

Upgrade your plan to export your entire recipes collection in one click.

Upgrade to Pro
Learn more

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