A general-purpose playbook for turning an offering — a community, a newsletter, an AI companion, a course, a service — into a tiered paid membership with Stripe. Distilled from the Spark / Flame / Hearth subscription system that OpenCosmos designed, built and later removed. Written to be handed to Claude Code: it explains the design decisions, gives fill-in templates, provides working code patterns for checkout, webhooks and entitlements, and lists the mistakes worth not repeating.
How to use this document. Give it to your Claude Code and say: "Read this guide, then interview me about my offering and build it." The guide is written so Claude Code can run the whole process — ask you the design questions, fill in the templates, write the code, and tell you what only you can do (Stripe Dashboard steps, legal text).
OpenCosmos once ran a three-tier paid membership — Spark, Flame, Hearth — wired to Stripe. It was built, shipped, and then deliberately removed when the strategy changed. This guide keeps what was learned: the shape of a tier structure that makes sense, the economics behind it, the integration that worked, and the lessons that cost real time. Nothing here is specific to OpenCosmos. Your offering is yours.
If you are Claude Code and a member has handed you this guide, follow this sequence. Do not skip to code.
sk_test_...). Never ask for, paste, print or commit a live secret key. Switching to live mode is the member's decision and a Dashboard action (Part 8).stripe listen and trigger test events, and confirm the whole loop works (checkout → entitlement granted → cancellation → entitlement removed) before declaring anything finished.A tier structure is a way of letting people choose how deeply they engage with something you've made. It works when the offering genuinely has depth levels. It fails when the tiers are the same thing at different prices with the honest framing removed.
The OpenCosmos lesson. Spark ($5) and Flame ($10) were, in the end, "Hearth without the honest framing." Hearth ($50) bundled real community membership plus managed access to the AI companion. The cheaper tiers sold slices of the AI's usage and little else, which made them transactional rather than meaningful, and they were abandoned. Before building tiers, ask whether each one corresponds to a real difference in what a person gets and who they get to be in your world.
Two framings, and why the second wins:
Design your tiers as a ladder of invitations. The question for every tier is "what does a person who wants to go deeper find here?" — not "what do I lock?"
Consider not having tiers at all if you have one offering and one audience. One clear price with an honest description often outperforms three muddy ones. Tiers earn their place when different people want genuinely different levels of involvement.
Claude Code: ask these. Members: answer in your own words; rough is fine.
A tier is only worth creating if it passes this test: "Name one specific thing a person at this tier gets that the tier below doesn't, which they would actually notice."
Typical axes of real difference:
| Axis | Example |
|---|---|
| Access | More content, earlier access, a private space |
| Usage | More of a metered resource (AI conversations, API calls, storage, seats) |
| Community | A seat at the table: live sessions, a members' forum, peer cohort |
| Contact | Direct time with you: office hours, reviews, 1:1 |
| Bundles | Membership in something else you run |
Avoid tiers that differ only by a number slightly larger than the one below. Avoid more than three tiers at the start; you can add one later more easily than you can remove one.
If your offering has marginal cost (AI usage, compute, a human's hour), price from it. If it has near-zero marginal cost (a community, a newsletter), price from the value and from what keeps you sustainable.
Net revenue per charge (Stripe's standard US card fee is roughly 2.9% + $0.30; check your actual rate):
net = price − (price × 0.029 + 0.30)
| Price | Stripe fee | Net |
|---|---|---|
| $5 | ~$0.45 | ~$4.55 |
| $10 | ~$0.59 | ~$9.41 |
| $50 | ~$1.75 | ~$48.25 |
Note what the fixed $0.30 does: at low prices it is a large percentage. A $5 tier loses ~9% to fees; a $50 tier loses ~3.5%. If you plan a low tier, know this up front.
Budget for marginal cost as a fraction of net, not of price. OpenCosmos allotted:
| Tier | Price | Share of net spent on marginal cost | Why |
|---|---|---|---|
| Low | $5 | ~50% | Thin tier; usage is the product |
| Mid | $10 | ~50% | Same |
| Top | $50 | ~20% | Most of the value was community with near-zero marginal cost; the rest was margin |
A reasonable rule: no more than half of net revenue may be spendable on marginal cost at any tier, and the more of a tier's value is non-metered (community, content), the smaller that share should be.
If a tier includes a metered resource, decide two things:
Track cost in the smallest practical integer unit, not floats (see Part 5.6).
Fill the Tier Table in Part 7. Name the tiers evocatively and consistently — OpenCosmos used a progression of warmth (Spark → Flame → Hearth): a first light, a sustained fire, a place to gather. Names should tell people where they are on a path.
Decide these before building. They are policy questions, not code questions:
Three systems, three jobs. Keep them separate.
Stripe Your app Your data store
────── ──────── ───────────────
Products & Prices ◄────── Checkout route Entitlement record
Checkout (hosted) ──────► (creates session) userId → tier, status,
Billing portal ◄────── Portal route stripe ids, period
Webhooks ──────► Webhook handler ──writes──► Reverse lookup
(the source of truth) stripeCustomerId → userId
│ Usage counters (optional)
▼
Access check (every
gated request reads
the entitlement)
The principle that makes this reliable: Stripe is the source of truth for billing; your webhook handler is the only thing that writes entitlements; your access check only ever reads them. The browser returning from Checkout is not proof of payment. Only the webhook is.
Everything in this part is done in the Stripe Dashboard, in Test mode, except where noted.
Create or choose a Stripe account. If you already run a Stripe account for another business, consider whether to keep them separate. OpenCosmos used a separate account from Creative Powerup so the two businesses' books, payouts and webhooks never tangled.
Create one Product per tier (Products → Add product). Name, description, and a recurring monthly price. Copy each Price ID (price_...).
Create a webhook endpoint (Developers → Webhooks). URL: https://<your-domain>/api/webhooks/stripe. Select these events and no more to begin with:
checkout.session.completedcustomer.subscription.updatedcustomer.subscription.deletedOptionally add invoice.payment_failed if you want to message people about failed payments. Copy the Signing secret (whsec_...).
Configure the Customer Portal (Settings → Billing → Customer portal). Turn on: cancel subscription, update payment method, view invoices, and (if you want self-serve) switch plans between your tiers. This is not optional — see Part 8.
Get your API keys (Developers → API keys). Test keys for now.
| Variable | Where from | Purpose |
|---|---|---|
STRIPE_SECRET_KEY | Dashboard → Developers → API keys | Server-side API access. sk_test_... in test, sk_live_... in production. Never in client code or git. |
STRIPE_WEBHOOK_SECRET | Dashboard → Developers → Webhooks → endpoint → Signing secret | Verifies webhooks really came from Stripe |
STRIPE_PRICE_<TIER> | Dashboard → Products → tier → Price ID | One per tier (e.g. STRIPE_PRICE_SPARK) |
Add them to .env.local for development (and make sure .env* is in .gitignore) and to your hosting provider's environment settings for production.
Hosting gotcha — monorepos with Turborepo. If your project uses Turborepo, environment variables are silently dropped at build and runtime unless declared in
turbo.jsonunderglobalPassThroughEnv. The build succeeds; the variables are justundefined. OpenCosmos lost real time to a webhook that failed only because its secret never arrived. If webhooks verify locally but fail in production, check this first. UseglobalPassThroughEnv, notglobalEnv, so secrets aren't hashed into the cache key.
TypeScript / Next.js route handlers shown. The structure matters more than the syntax.
// lib/tiers.ts
export const TIERS = {
spark: {
priceId: process.env.STRIPE_PRICE_SPARK!,
name: 'Spark',
description: 'One line a person would actually say yes to.',
monthlyUSD: 5,
features: ['…', '…'],
// Only if you meter anything:
monthlyBudgetMicrodollars: 2_280_000, // from Part 2.3
weeklyBudgetMicrodollars: 570_000, // monthly ÷ 4
},
// flame, hearth …
} as const
export type Tier = keyof typeof TIERS
// Map a Stripe price ID back to your tier key. Null if unrecognized.
export function tierFromPriceId(priceId: string): Tier | null {
for (const [key, val] of Object.entries(TIERS)) {
if (val.priceId === priceId) return key as Tier
}
return null
}
Everything else — pricing page, checkout validation, webhook mapping, access checks — reads from this file. Change a tier in one place.
// lib/stripe.ts
import Stripe from 'stripe'
// Instantiate inside handlers, not at module scope. Env vars can be undefined
// during build-time static analysis, which would crash the build.
export function getStripe(): Stripe {
return new Stripe(process.env.STRIPE_SECRET_KEY!) // use the current API version per Stripe's docs
}
Creates a Stripe Checkout Session and returns a URL for the browser to redirect to.
// app/api/stripe/checkout/route.ts
export async function POST(req: NextRequest) {
const user = await getSignedInUser(req) // however your app does auth
if (!user) return NextResponse.json({ error: 'Sign in to subscribe.' }, { status: 401 })
const { tier } = await req.json().catch(() => ({}))
if (!tier || !(tier in TIERS)) {
return NextResponse.json({ error: 'Invalid tier.' }, { status: 400 })
}
const origin = req.headers.get('origin') ?? 'https://your-domain.com'
const session = await getStripe().checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: TIERS[tier as Tier].priceId, quantity: 1 }],
// THE LINK between Stripe and your user system. The webhook reads this.
client_reference_id: user.id,
customer_email: user.email,
success_url: `${origin}/welcome`,
cancel_url: `${origin}/account`,
subscription_data: { metadata: { user_id: user.id, tier } },
})
return NextResponse.json({ url: session.url })
}
Key decisions embedded here:
client_reference_id carries your user's ID. It is how the webhook knows who paid.tier against your own table. Never pass a client-supplied price ID straight to Stripe.// app/api/webhooks/stripe/route.ts
export async function POST(req: NextRequest) {
// RAW string body. Do not parse it first.
const rawBody = await req.text()
const sig = req.headers.get('stripe-signature') ?? ''
let event: Stripe.Event
try {
event = getStripe().webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
if (session.mode !== 'subscription') break
const userId = session.client_reference_id
if (!userId) break
const sub = await getStripe().subscriptions.retrieve(session.subscription as string)
const priceId = sub.items.data[0]?.price.id ?? ''
const tier = tierFromPriceId(priceId)
if (!tier) break
await setEntitlement(userId, {
tier,
stripeCustomerId: session.customer as string,
stripeSubscriptionId: sub.id,
status: sub.status === 'active' ? 'active' : 'past_due',
})
await provisionBenefits(tier, userId) // best-effort, see 5.5
break
}
case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription
const userId = await userIdFromCustomerId(sub.customer as string)
if (!userId) break // safe to ignore: not ours yet
const tier = tierFromPriceId(sub.items.data[0]?.price.id ?? '')
if (!tier) break
await setEntitlement(userId, {
tier,
stripeCustomerId: sub.customer as string,
stripeSubscriptionId: sub.id,
status: sub.status === 'active' ? 'active'
: sub.status === 'past_due' ? 'past_due'
: 'canceled',
})
break
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription
const userId = await userIdFromCustomerId(sub.customer as string)
if (userId) {
await deleteEntitlement(userId, sub.customer as string)
await revokeBenefits(tierFromPriceId(sub.items.data[0]?.price.id ?? ''), userId)
}
break
}
default:
break // acknowledge everything else so Stripe stops retrying it
}
return NextResponse.json({ received: true })
}
The rules this handler follows, and why:
req.json() first and re-serialize, the signature will never match. This is the single most common failure.canceled and deleted differently from past_due. past_due is a payment problem Stripe is still retrying; deleted is over.If a tier includes things outside your app — a newsletter, a community seat, a course enrollment, a Discord role — grant and revoke them from the webhook.
OpenCosmos's matrix:
| Tier | Newsletter (Substack) | Community (Circle) |
|---|---|---|
| Spark | — | — |
| Flame | ✓ | — |
| Hearth | ✓ | ✓ |
checkout.session.completed
→ look up the user's email + name in your user system
→ provisionBenefits(tier, email, name)
→ addNewsletterSubscriber() (if tier includes it)
→ addCommunityMember() (if tier includes it)
customer.subscription.deleted
→ revokeBenefits(tier, email)
→ removeCommunityMember() (if tier included it)
→ (newsletter: left subscribed on purpose)
Patterns that held up:
Promise.allSettled, never Promise.all. One failing integration must not block the others, and none may block the webhook's 200.[benefits/circle] add member failed). Manual repair path: find the subscriber in Stripe, add them by hand.STRIPE_SECRET_KEY: environment variables only.Only needed if a tier includes a metered resource (AI conversation, API calls, etc.).
Store cost as integers in the smallest unit, not as dollars. OpenCosmos tracked microdollars (1 µ$ = $0.000001) so every counter increment was an atomic integer INCRBY. Floating-point accumulation drifts and can't be incremented atomically in most stores.
For an LLM, convert per-token pricing into per-token microdollars (price per million tokens equals microdollars per token numerically — e.g. $3 per million input tokens is 3 µ$ per token). Use the provider's current price list; prices change, and the numbers in this guide are illustrative.
Counters, with TTLs that outlive their period so late events don't vanish:
| Key pattern | Value | TTL |
|---|---|---|
entitlement:{userId} | JSON: tier, status, Stripe IDs | ~13 months, refreshed on each webhook |
stripe_customer:{customerId} | userId (reverse lookup for webhooks) | ~13 months |
usage:monthly:{userId}:{YYYY-M} | integer µ$ | ~40 days |
usage:weekly:{userId}:{YYYY-WW} | integer µ$ | ~12 days |
Redis (e.g. Upstash) was the store. Any store with atomic increments and expiry works.
Why the reverse lookup matters. The checkout session carries your userId, but later subscription events (updated, deleted) carry only the Stripe customer ID. Write customerId → userId the first time a subscription is created, or you will not be able to find the person when they cancel.
Prompt caching reduces the cost of conversational AI substantially. If your metered resource is an LLM and your provider supports prompt caching, cache the stable system prompt on every request. OpenCosmos found this the largest single cost reduction (roughly three-quarters or more of input cost eliminated after the first exchange of a session) and chose to not count cached reads against the subscriber's budget — generous to them, and cheap for the business.
Every gated request evaluates, in priority order. OpenCosmos's order, as a template:
429 and say which period was exhausted (weekly or monthly) so the UI can tell them honestly when it resets.The access check only reads entitlements. It never calls Stripe on the hot path.
A person must be able to see and manage what they're paying for:
stripe.billingPortal.sessions.create({ customer, return_url })) and redirects. This is how they cancel, change cards, and download invoices.// app/api/stripe/portal/route.ts
export async function POST(req: NextRequest) {
const user = await getSignedInUser(req)
if (!user) return NextResponse.json({ error: 'Sign in.' }, { status: 401 })
const ent = await getEntitlement(user.id)
if (!ent) return NextResponse.json({ error: 'No active subscription.' }, { status: 404 })
const session = await getStripe().billingPortal.sessions.create({
customer: ent.stripeCustomerId,
return_url: `${new URL(req.url).origin}/account`,
})
return NextResponse.json({ url: session.url })
}
These are the things that actually went wrong or nearly did.
1. Parse nothing before you verify. Stripe verification needs the raw body string. Other providers differ — OpenCosmos's WorkOS webhooks needed the opposite (a parsed object, which the SDK re-serializes internally). Never assume; read the SDK for the provider you're calling. A mismatch produces a permanent 400 Invalid signature on every delivery, with no other symptom.
2. Environment variables vanish quietly. See the Turborepo note in Part 4. Also: Vercel and similar hosts need the variables set separately from your local .env.local, and must be redeployed afterward.
3. Auth middleware can silently return "not logged in." With some auth libraries (WorkOS AuthKit on Next.js 16+ in OpenCosmos's case), a required proxy.ts / middleware.ts file injects a header that the session helper needs. Without it, the helper returns user: null for every request even with a valid cookie — the user looks logged in everywhere except where it matters. If checkout returns 401 for a signed-in user, check this before anything else. Use a broad matcher with static-asset exclusions, not a catch-all, or you'll intercept CSS assets and break styling.
4. Webhook handlers are the product. Most of the effort in a Stripe integration is not checkout; it's making the webhook handler correct under retries, reordering, partial failure and unknown inputs.
5. Test the whole lifecycle, not the happy path. Use the Stripe CLI to trigger:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
Also test: payment failure (use Stripe's failing test cards), upgrade, downgrade, cancel-at-period-end, and a webhook delivered twice.
6. Don't build on a tier you've decided to abandon. OpenCosmos's tier code sat in production for five months after the strategy changed, "kept only so existing subscribers can manage billing" — a surface to keep working and something every future reader had to reason around. If you stop selling a tier, plan its exit (Part 8).
7. Keep unrelated things in separate files. The original subscription module held three unrelated concerns — subscription records, usage budget counters, and bring-your-own-key tracking. Removing the tiers meant careful surgery rather than a deletion. Keep entitlements, metering and each side-feature in their own modules from the start.
Claude Code: produce these with the member, and keep them in the project (e.g. docs/offerings.md) so future sessions have them.
# Offering Sheet — <name>
**The thing, in one sentence:**
**Who it's for:**
**What they're trying to become or do:**
**What I give away free, forever:**
**What costs me something each time it's used:**
**The invitation (one or two sentences that explain, without pressure, why someone would go deeper):**
**Brand voice (three adjectives, and one thing we never say):**
| | <Tier 1> | <Tier 2> | <Tier 3> |
|---|---|---|---|
| **Name** | | | |
| **One-line description** | | | |
| **Price / month** | | | |
| **Stripe fee** (price × 2.9% + $0.30) | | | |
| **Net revenue** | | | |
| **Marginal-cost budget** (% of net) | | | |
| **Monthly budget / weekly cap** | | | |
| **Included: access** | | | |
| **Included: usage** | | | |
| **Included: community** | | | |
| **Included: contact** | | | |
| **Bundled benefits** | | | |
| **"What do they notice?"** (one thing this tier has that the one below doesn't) | | | |
**Upgrade:** immediate / next renewal · prorated yes/no
**Downgrade:** immediate / next renewal
**Cancellation:** keeps access until end of paid period yes/no
**Failed payment grace period:** __ days
**Refund policy:**
**Benefits revoked on cancellation:** (list each, and which are intentionally kept)
- [ ] Stripe account chosen (shared or separate):
- [ ] Product + recurring price created per tier; price IDs recorded in env
- [ ] Webhook endpoint created with the three events; signing secret in env
- [ ] Customer Portal configured (cancel, payment method, invoices, plan switching)
- [ ] Env vars set locally AND on the host (and declared in turbo.json if applicable)
- [ ] Test-mode lifecycle verified with `stripe listen` / `stripe trigger`
- [ ] Failed-payment, upgrade, downgrade, cancel tested
- [ ] Terms, privacy and refund pages published
- [ ] Tax settings reviewed (Stripe Tax or advice)
- [ ] Live keys swapped in by the member, and a real small purchase made and refunded
<key>: {
priceId: process.env.STRIPE_PRICE_<KEY>!,
name: '<Name>',
description: '<one line>',
monthlyUSD: <n>,
features: ['<…>'],
monthlyBudgetMicrodollars: <n>, // omit if nothing is metered
weeklyBudgetMicrodollars: <n>, // monthly ÷ 4
},
price_... IDs and a new whsec_... signing secret result.OpenCosmos's tiers were removed from the code in September 2026. The Stripe products, prices and webhook endpoints were still sitting in the Stripe account, and the removal of the billing portal meant that any subscriber who was still active kept being billed and could no longer cancel from the site. Deleting code does not end a subscription.
If you ever retire a tier or the whole system:
The tiers OpenCosmos built were, in the end, replaced by something simpler: community as the compounding asset. A subscription is linear — five dollars a month, per person. A community compounds: every member who shows up makes membership more valuable for the next. Build tiers if your offering truly has depths. But whatever you charge, make it an invitation — free at the door, deeper where the community gathers — and let the tiers describe the paths people can take, not the walls they run into.
Source: this guide is distilled from OpenCosmos's retired subscription system (Stripe integration, tier economics, microdollar metering, webhook handling, and Substack/Circle benefit provisioning), preserved in the OpenCosmos repository's archive of removed documentation.
This text lives in the commons
Spotted a typo, an error, or something missing? The source is public, and a correction reaches the Library once it has been reviewed.