| Slug | Mode | Amount | Interval | Features | Channels | Live price | Test price |
|---|---|---|---|---|---|---|---|
creator-monthly |
subscription |
2900 usd | month |
publishing |
15 | not sold | price_1UTNrVATDpUETzMdR7nBnsUO |
creator-yearly |
subscription |
29000 usd | year |
publishing |
15 | not sold | price_1UTNrWATDpUETzMdeOWmnJEZ |
growth-monthly |
subscription |
6900 usd | month |
publishing |
50 | not sold | price_1UTNrWATDpUETzMdSqIqkyHD |
growth-yearly |
subscription |
69000 usd | year |
publishing |
50 | not sold | price_1UTNrXATDpUETzMdv7UygyVo |
pro |
payment |
16900 usd | none | boilerplate, lifetime-updates |
0 | not sold | price_1UTMg1ATDpUETzMdfZYdop8n |
Amounts are minor units, mirroring Stripe's unit_amount. The catalog is packages/shared/src/catalog.ts, it is a TypeScript file rather than a database table, and there is no admin UI for editing it.
Each plan is two offers, one per billing interval, so creator-monthly and creator-yearly grant the same features and Channel limit and differ only in price and interval. The pricing section's Monthly and Yearly toggle picks which of each pair renders. pro is the inherited one-time offer: it keeps a test price so one-time purchase support stays exercised, and UNLISTED_SLUGS in card-copy.ts keeps it off the pricing section. See docs/adr/0041-subscriptions-limited-by-channel-count.md.
pro's live Stripe product is named Premium. That is a dead name, kept only because the product already exists in the live account. The slug is pro.
The shape of an entry
type CatalogEntry =
| (CatalogEntryCommon & {
mode: 'payment'
interval?: never
trialDays?: never
})
| (CatalogEntryCommon & {
mode: 'subscription'
interval: 'month' | 'year'
trialDays?: number
})
type CatalogEntryCommon = {
features: readonly Feature[]
channelLimit: number
unitAmount: number
currency: Currency
priceId: Readonly<Record<StripeEnv, string | null>>
}
interval is required when the mode is subscription and forbidden when it is payment, so a one-time price with /mo beside it is a type error rather than a code review.
trialDays is the free days before the first charge, and it is optional on a subscription offer and forbidden on a payment one. Every Postflare plan sets 7. Setting it is the whole trial: checkout passes it to Stripe as subscription_data.trial_period_days, trialing entitles for free, and customer.subscription.trial_will_end mails the warning three days before the charge. See Add a plan.
The entry is slug-major: mode, features and the amount belong to the offer, so they are declared once. Only priceId varies by Stripe environment, because live and sandbox prices live in two genuinely different accounts whose ids are indistinguishable to the type system.
priceId[env] of null means this offer is not sold in that environment. It fails loudly rather than falling back.
channelLimit is how many Channels a Creator holding the offer can connect. GET /user/entitlements returns the largest limit among the offers a user holds, as channelLimit, and 0 when they hold none; limits do not add up. The pricing card and the dashboard read it rather than authored copy, so neither can promise a limit the entitlement does not grant.
Features
| Feature | Granted by |
|---|---|
publishing |
every Creator and Growth offer |
boilerplate |
pro |
lifetime-updates |
pro |
These are machine keys an entitlement is expressed in. They are not the marketing bullets on the pricing card, and the two need not correspond. Card copy lives in CARD_COPY in apps/web/src/lib/catalog/card-copy.ts.
The functions
| Function | Direction | Returns |
|---|---|---|
resolveSlug(slug, env) |
checkout | { slug, priceId, entry }, or throws CatalogError |
slugForPriceId(priceId) |
webhook | a Slug, or null |
sellableSlugs(env) |
pricing page | every slug with a price in that environment |
isSlug(value) |
narrowing | a type predicate |
stripeEnvFromSecretKey(key) |
the api's boot | live or test |
resolveSlug is the only way in. A client sends a slug, and the api resolves it to a price server-side. A caller who could name a price could pay the cheapest one in your Stripe account and ask for the most expensive offer, which is a live exploit the reference implementation shipped.
CatalogError carries two codes, and collapsing them would hide the one that is yours:
| Code | HTTP | Means |
|---|---|---|
unknown_slug |
400 | no such offer, which is the caller's problem |
slug_not_sold_here |
503 stripe_env_mismatch |
the offer exists and has no price in this api's account |
The second means the web app and the api are pointed at different Stripe environments, which is a deploy-time configuration problem readable in one log line rather than a broken button on the money page.
slugForPriceId is derived at module load rather than authored, so the two directions cannot drift. Building it asserts that no price id appears under two slugs, and that assertion throws at startup: an ambiguous price would otherwise silently grant whichever slug happened to be enumerated first.
Which Stripe environment is active
Derived from the secret key's prefix, never from NODE_ENV:
export function stripeEnvFromSecretKey(secretKey: string): StripeEnv {
return secretKey.startsWith('sk_live_') || secretKey.startsWith('rk_live_')
? 'live'
: 'test'
}
The web build mode answers a different question, which is how the process was built. A production Vite build can still point at Stripe's test account, so collapsing the two axes would render live prices merely because somebody ran pnpm build.
apps/web holds no Stripe secret, so it cannot derive the billing environment from
a key. It is told, through VITE_STRIPE_ENV, which is a Zod enum with no default. GET /health reports the api's answer, which is how you check the two agree.
Adding a slug
SLUGS is a const array and CATALOG is a Record<Slug, CatalogEntry>, so adding a name to SLUGS is a type error until an entry exists for it. CARD_COPY in the pricing component is exhaustive in the same way, so it is a second type error until copy exists too.
That pair of errors is the point. An array of cards would let a new offer go silently unrendered, and a card that vanishes from the money page is the failure worth compiling against.
The full recipe is Add a plan.
Single currency, on purpose
Currency is the union 'usd', and amounts are minor units. Dividing by 100 to display is wrong for zero-decimal currencies, which is the first thing multi-currency breaks. Widening that union is the deliberate edit that starts that work.
Related
docs/adr/0012-slug-major-catalog-carries-prices.md, the shape and why prices live in codedocs/adr/0002-express-api-owns-every-secret.md, why a client sends a slugdocs/adr/0005-entitlement-derived-from-price-paid.md, the reverse lookup
