Billing and Subscriptions with Stripe in Makerkit Next.js Prisma

Implement Stripe subscription billing for your SaaS application. Configure pricing tiers, enforce feature limits on boards and feedback, build usage dashboards, and create upgrade prompts that drive conversions.

With organizations and role-based access control in place, TeamPulse has a solid multi-tenant foundation. Now it's time to monetize—without billing, you have a project, not a business. This module implements Stripe subscriptions with real feature limits that create natural upgrade paths.

You'll build something more sophisticated than a simple paywall. TeamPulse will enforce contextual limits: board creation limited by plan, feedback items limited per board. Users will see exactly where they stand and what they'll unlock by upgrading.

Time: ~2-3 hours

Technologies used:

  • Stripe - Payment processing
  • Better Auth Stripe Plugin - Subscription management

What you'll accomplish:

  • Configure Stripe for local development
  • Define TeamPulse plans with board and feedback limits
  • Gate board creation by subscription plan
  • Gate feedback submission by per-board limits
  • Build a usage dashboard component
  • Create upgrade prompt components

Prerequisites: Complete Module 7: Organizations & Teams first.


TeamPulse Pricing Strategy

Before configuring Stripe, let's define TeamPulse's pricing tiers. Effective SaaS pricing isn't arbitrary—each limit should correspond to how customers actually use the product and when they naturally need more.

FeatureFreeStarter ($9/mo)Pro ($29/mo)Enterprise ($99/mo)
Boards1310Unlimited
Feedback/board25100500Unlimited
Team members2515Unlimited
Public boardsNoYesYesYes
Export dataNoNoYesYes
Custom brandingNoNoNoYes

Why these specific limits?

  • Free tier lets users experience the core value (feedback collection) but constrains scale. One board with 25 items is enough to evaluate, but not enough to run a real feedback program.
  • Starter unlocks the features small teams need: multiple boards for different products/projects, enough feedback volume for active use, and public boards for customer-facing feedback.
  • Pro targets growing teams who need capacity. The 10x jump in feedback limits (500 vs 50) justifies the 3x price increase. Export data becomes valuable once you have substantial feedback to analyze.
  • Enterprise removes all limits for large organizations. Custom branding appeals to companies that embed TeamPulse in their own products.

This structure creates natural upgrade moments: "I need a second board," "we're hitting feedback limits," "we want to share publicly."


Step 1: Configure Stripe

Create a Stripe Account

  1. Go to Stripe Dashboard
  2. Create an account (or sign in)
  3. Stay in Test Mode (toggle in the dashboard)

Get Your API Keys

  1. Go to Developers → API Keys
  2. Copy your Publishable key (starts with pk_test_)
  3. Copy your Secret key (starts with sk_test_)

Add to apps/web/.env.local:

# Stripe Keys
STRIPE_SECRET_KEY=sk_test_your_secret_key
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key

Set Up Webhook (Local Development)

# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/auth/stripe/webhook

Copy the webhook signing secret (starts with whsec_) to .env.local:

STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret

Keep stripe listen running while testing billing.

Create Products in Stripe

Go to Products in Stripe Dashboard and create:

1. TeamPulse Starter

  • Monthly: $9.00/month
  • Yearly: $90.00/year

2. TeamPulse Pro

  • Monthly: $29.00/month
  • Yearly: $290.00/year

3. TeamPulse Enterprise

  • Monthly: $99.00/month
  • Yearly: $990.00/year

Copy each Price ID (starts with price_).


Step 2: Define TeamPulse Plan Limits

The billing configuration lives in packages/billing/config/src/config.ts. This file defines your products, plans, and their limits. The BillingConfig type from @kit/billing provides full type safety.

Configure the Billing Plans

File: packages/billing/config/src/config.ts

import { BillingConfig } from '@kit/billing';
export const billingConfig: BillingConfig = {
// Default limits for users without a subscription (free tier)
defaultLimits: {
seats: 2,
boards: 1,
feedbackPerBoard: 25,
publicBoards: 0, // 0 = false for boolean features
exportData: 0,
customBranding: 0,
},
products: [
// Starter Plan
{
id: 'starter',
name: 'Starter',
description: 'For small teams getting started',
currency: 'USD',
features: [
'Up to 3 feedback boards',
'100 feedback items per board',
'5 team members',
'Public boards',
],
plans: [
{
name: 'starter-monthly',
planId: process.env.STRIPE_PRICE_STARTER_MONTHLY!,
displayName: 'Starter',
interval: 'month',
cost: 9.00,
limits: {
seats: 5,
boards: 3,
feedbackPerBoard: 100,
publicBoards: 1,
exportData: 0,
customBranding: 0,
},
},
{
name: 'starter-yearly',
planId: process.env.STRIPE_PRICE_STARTER_YEARLY!,
displayName: 'Starter',
interval: 'year',
cost: 90.00,
limits: {
seats: 5,
boards: 3,
feedbackPerBoard: 100,
publicBoards: 1,
exportData: 0,
customBranding: 0,
},
},
],
},
// Pro Plan
{
id: 'pro',
name: 'Pro',
description: 'For growing teams',
currency: 'USD',
badge: 'Popular',
highlighted: true,
features: [
'Up to 10 feedback boards',
'500 feedback items per board',
'15 team members',
'Public boards',
'Export data',
],
plans: [
{
name: 'pro-monthly',
planId: process.env.STRIPE_PRICE_PRO_MONTHLY!,
displayName: 'Pro',
interval: 'month',
cost: 29.00,
limits: {
seats: 15,
boards: 10,
feedbackPerBoard: 500,
publicBoards: 1,
exportData: 1,
customBranding: 0,
},
},
{
name: 'pro-yearly',
planId: process.env.STRIPE_PRICE_PRO_YEARLY!,
displayName: 'Pro',
interval: 'year',
cost: 290.00,
limits: {
seats: 15,
boards: 10,
feedbackPerBoard: 500,
publicBoards: 1,
exportData: 1,
customBranding: 0,
},
},
],
},
// Enterprise Plan
{
id: 'enterprise',
name: 'Enterprise',
description: 'For large organizations',
currency: 'USD',
features: [
'Unlimited feedback boards',
'Unlimited feedback items',
'Unlimited team members',
'Public boards',
'Export data',
'Custom branding',
'Priority support',
],
plans: [
{
name: 'enterprise-monthly',
planId: process.env.STRIPE_PRICE_ENTERPRISE_MONTHLY!,
displayName: 'Enterprise',
interval: 'month',
cost: 99.00,
limits: {
seats: null, // null = unlimited
boards: null,
feedbackPerBoard: null,
publicBoards: 1,
exportData: 1,
customBranding: 1,
},
},
{
name: 'enterprise-yearly',
planId: process.env.STRIPE_PRICE_ENTERPRISE_YEARLY!,
displayName: 'Enterprise',
interval: 'year',
cost: 990.00,
limits: {
seats: null,
boards: null,
feedbackPerBoard: null,
publicBoards: 1,
exportData: 1,
customBranding: 1,
},
},
],
},
],
};
export default billingConfig;

Key points:

  • Use planId field with the Stripe price ID directly
  • Use defaultLimits for free tier (users without subscriptions)
  • null values mean unlimited
  • For boolean features, use 1 (enabled) or 0 (disabled) since limits are number | null

Add Price IDs to Environment

# apps/web/.env.local
# Starter Plan
STRIPE_PRICE_STARTER_MONTHLY=price_xxxxx
STRIPE_PRICE_STARTER_YEARLY=price_xxxxx
# Pro Plan
STRIPE_PRICE_PRO_MONTHLY=price_xxxxx
STRIPE_PRICE_PRO_YEARLY=price_xxxxx
# Enterprise Plan
STRIPE_PRICE_ENTERPRISE_MONTHLY=price_xxxxx
STRIPE_PRICE_ENTERPRISE_YEARLY=price_xxxxx

Step 3: Create Plan Limit Utilities

The SaaS Kit provides a unified billing client via getBilling(auth). This client handles subscription lookup, plan limits, and all billing operations. For TeamPulse, we'll create app-specific helpers that wrap the billing client.

Using the Billing Client

The billing client is obtained by calling getBilling(auth):

import { getBilling } from '@kit/billing/api';
import { auth } from '@kit/auth';
const billing = await getBilling(auth);
// Get all limits for a referenceId
const { limits, hasSubscription } = await billing.getPlanLimits(organizationId);
// Check a specific limit
const { allowed, limit, remaining } = await billing.checkPlanLimit({
referenceId: organizationId,
limitKey: 'seats',
currentUsage: memberCount,
});
// Access default limits (free tier)
const defaultLimits = billing.defaultPlanLimits;

TeamPulse-Specific Utilities

For TeamPulse, we create wrapper utilities that combine limit checking with usage queries:

File: apps/web/lib/billing/plan-limits.ts

import 'server-only';
import { cache } from 'react';
import { getBilling } from '@kit/billing/api';
import { auth } from '@kit/auth';
import { getActiveOrganizationId } from '@kit/better-auth/context';
import { db } from '@kit/database';
// Check if organization can create more boards
export const canCreateBoard = cache(async () => {
const organizationId = await getActiveOrganizationId();
if (!organizationId) {
return { allowed: false, current: 0, limit: 0, remaining: 0, hasSubscription: false };
}
// Count current boards
const currentCount = await db.board.count({
where: { organizationId },
});
// Get billing client and check limit
const billing = await getBilling(auth);
return billing.checkPlanLimit({
referenceId: organizationId,
limitKey: 'boards',
currentUsage: currentCount,
});
});
// Check if board can accept more feedback
export const canCreateFeedback = cache(async (boardId: string) => {
const organizationId = await getActiveOrganizationId();
if (!organizationId) {
return { allowed: false, current: 0, limit: 0, remaining: 0, hasSubscription: false };
}
// Count current feedback in board
const currentCount = await db.feedbackItem.count({
where: { boardId },
});
const billing = await getBilling(auth);
return billing.checkPlanLimit({
referenceId: organizationId,
limitKey: 'feedbackPerBoard',
currentUsage: currentCount,
});
});
// Check boolean features (using numeric limits: 1 = true, 0 = false)
export const canCreatePublicBoard = cache(async (): Promise<boolean> => {
const organizationId = await getActiveOrganizationId();
const billing = await getBilling(auth);
if (!organizationId) {
return (billing.defaultPlanLimits.publicBoards ?? 0) > 0;
}
const { limits } = await billing.getPlanLimits(organizationId);
return (limits.publicBoards ?? 0) > 0;
});
export const canExportData = cache(async (): Promise<boolean> => {
const organizationId = await getActiveOrganizationId();
const billing = await getBilling(auth);
if (!organizationId) {
return (billing.defaultPlanLimits.exportData ?? 0) > 0;
}
const { limits } = await billing.getPlanLimits(organizationId);
return (limits.exportData ?? 0) > 0;
});

Key points:

  • Use getBilling(auth) to obtain the billing client
  • checkPlanLimit handles subscription lookup and limit comparison
  • defaultPlanLimits provides free tier limits from the config
  • null values in limits mean unlimited; missing keys are also treated as unlimited

Step 4: Gate Board Creation

With limit utilities defined, integrate them into the board creation flow. The key is checking limits in the server action (where enforcement happens) and passing limit data to the UI (for display and disabling).

Update the Server Action

File: apps/web/lib/boards/boards-server-actions.ts

import { canCreateBoard } from '@lib/billing/plan-limits';
export const createBoardAction = authenticatedActionClient
.inputSchema(createBoardSchema)
.action(async ({ parsedInput: data, ctx }) => {
const logger = await getLogger();
const userId = ctx.user.id;
const organizationId = await getActiveOrganizationId();
if (!organizationId) {
throw new Error('No active organization');
}
// Check board limit
const { allowed, current, limit, hasSubscription } = await canCreateBoard();
if (!allowed) {
if (!hasSubscription) {
throw new Error('Upgrade to a paid plan to create more boards.');
}
throw new Error(
`Board limit reached (${current}/${limit}). ` +
'Upgrade your plan for more boards.'
);
}
logger.info(
{ userId, organizationId, name: data.name },
'Creating board',
);
// ... rest of the action
});

Update the Create Board Dialog

Show the limit in the UI and disable the button when at limit.

File: apps/web/app/[locale]/(internal)/boards/_components/create-board-dialog.tsx

'use client';
import { useState } from 'react';
import { Plus, AlertCircle } from 'lucide-react';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import { Alert, AlertDescription } from '@kit/ui/alert';
import { CreateBoardForm } from './create-board-form';
interface CreateBoardDialogProps {
boardLimit: {
allowed: boolean;
current: number;
limit: number | null;
remaining: number | null;
};
}
export function CreateBoardDialog({ boardLimit }: CreateBoardDialogProps) {
const [open, setOpen] = useState(false);
const isAtLimit = !boardLimit.allowed;
const limitText = boardLimit.limit
? `${boardLimit.current}/${boardLimit.limit} boards used`
: `${boardLimit.current} boards`;
return (
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">{limitText}</span>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button disabled={isAtLimit} data-testid="create-board-button">
<Plus className="mr-2 h-4 w-4" />
Create Board
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Feedback Board</DialogTitle>
<DialogDescription>
Create a new board to collect feedback from your team.
</DialogDescription>
</DialogHeader>
<CreateBoardForm onSuccess={() => setOpen(false)} />
</DialogContent>
</Dialog>
{isAtLimit && (
<UpgradePrompt feature="boards" />
)}
</div>
);
}

Pass Limit Data from Page

File: apps/web/app/[locale]/(internal)/boards/page.tsx

import { canCreateBoard } from '@lib/billing/plan-limits';
export default async function BoardsPage() {
const boards = await loadBoards();
const boardLimit = await canCreateBoard();
return (
<PageBody>
<PageHeader>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Feedback Boards</h1>
<p className="text-muted-foreground">
Create boards to collect feedback from your team
</p>
</div>
<CreateBoardDialog boardLimit={boardLimit} />
</div>
</PageHeader>
{/* ... rest of the page */}
</PageBody>
);
}

Step 5: Gate Feedback Submission

Apply the same gating pattern to feedback creation. The difference here is that limits are per-board, not per-organization. This creates more granular upgrade pressure—users might hit feedback limits on one board while others have room.

Update the Server Action

File: apps/web/lib/feedback/feedback-server-actions.ts

import { canCreateFeedback } from '@lib/billing/plan-limits';
export const createFeedbackAction = authenticatedActionClient
.inputSchema(createFeedbackSchema)
.action(async ({ parsedInput: data, ctx }) => {
const logger = await getLogger();
const userId = ctx.user.id;
const organizationId = await getActiveOrganizationId();
if (!organizationId) {
throw new Error('No active organization');
}
// Check feedback limit for this board
const { allowed, current, limit, hasSubscription } =
await canCreateFeedback(data.boardId);
if (!allowed) {
if (!hasSubscription) {
throw new Error('Upgrade to a paid plan to add more feedback.');
}
throw new Error(
`Feedback limit reached for this board (${current}/${limit}). ` +
'Upgrade your plan for more capacity.'
);
}
// ... rest of the action
});

Show Limit in Feedback Dialog

File: apps/web/app/[locale]/(internal)/boards/[boardId]/_components/create-feedback-dialog.tsx

interface CreateFeedbackDialogProps {
boardId: string;
feedbackLimit: {
allowed: boolean;
current: number;
limit: number | null;
remaining: number | null;
};
}
export function CreateFeedbackDialog({
boardId,
feedbackLimit,
}: CreateFeedbackDialogProps) {
const [open, setOpen] = useState(false);
const isAtLimit = !feedbackLimit.allowed;
const limitText = feedbackLimit.limit
? `${feedbackLimit.current}/${feedbackLimit.limit} items`
: `${feedbackLimit.current} items`;
return (
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">{limitText}</span>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
data-testid="create-feedback-button"
render={children as React.ReactElement}
/>
<DialogContent>
{isAtLimit ? (
<>
<DialogHeader>
<DialogTitle>Feedback Limit Reached</DialogTitle>
<DialogDescription>
This board has reached its feedback limit.
</DialogDescription>
</DialogHeader>
<UpgradePrompt feature="feedback" />
</>
) : (
<>
<DialogHeader>
<DialogTitle>Submit Feedback</DialogTitle>
<DialogDescription>
Share a bug report, feature request, or idea.
</DialogDescription>
</DialogHeader>
<CreateFeedbackForm
boardId={boardId}
onSuccess={() => setOpen(false)}
/>
</>
)}
</DialogContent>
</Dialog>
</div>
);
}

Step 6: Create the Usage Dashboard

Users need visibility into their usage. A dashboard that shows current consumption against limits helps users understand their plan's value and anticipate when they'll need to upgrade. This transparency builds trust and reduces support requests about "why can't I create more boards?"

File: apps/web/app/[locale]/(internal)/settings/billing/_components/usage-dashboard.tsx

import { Progress } from '@kit/ui/progress';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { getBilling } from '@kit/billing/api';
import { auth } from '@kit/auth';
import { getActiveOrganizationId } from '@kit/better-auth/context';
import { canCreateBoard } from '@lib/billing/plan-limits';
import { loadBoards } from '@lib/boards/boards-page.loader';
export async function UsageDashboard() {
const organizationId = await getActiveOrganizationId();
const billing = await getBilling(auth);
// Get plan limits (falls back to defaultLimits if no subscription)
const { limits, hasSubscription } = organizationId
? await billing.getPlanLimits(organizationId)
: { limits: billing.defaultPlanLimits, hasSubscription: false };
const boardLimit = await canCreateBoard();
const boards = await loadBoards();
// Calculate total feedback across all boards
const totalFeedback = boards.reduce(
(sum, board) => sum + (board.feedbackCount ?? 0),
0,
);
return (
<Card>
<CardHeader>
<CardTitle>Usage</CardTitle>
{!hasSubscription && (
<p className="text-sm text-muted-foreground">Free tier</p>
)}
</CardHeader>
<CardContent className="space-y-6">
{/* Boards Usage */}
<UsageItem
label="Feedback Boards"
current={boardLimit.current}
limit={boardLimit.limit}
/>
{/* Per-board breakdown */}
<div className="space-y-3">
<h4 className="text-sm font-medium">Feedback per Board</h4>
{boards.map((board) => (
<UsageItem
key={board.id}
label={board.name}
current={board.feedbackCount ?? 0}
limit={limits.feedbackPerBoard ?? null}
size="sm"
/>
))}
</div>
{/* Features */}
<div className="space-y-2">
<h4 className="text-sm font-medium">Features</h4>
<div className="grid gap-2 text-sm">
<FeatureRow
label="Public boards"
enabled={limits.publicBoards ?? false}
/>
<FeatureRow
label="Export data"
enabled={limits.exportData ?? false}
/>
<FeatureRow
label="Custom branding"
enabled={limits.customBranding ?? false}
/>
</div>
</div>
</CardContent>
</Card>
);
}
interface UsageItemProps {
label: string;
current: number;
limit: number | null;
size?: 'default' | 'sm';
}
function UsageItem({ label, current, limit, size = 'default' }: UsageItemProps) {
const percentage = limit ? Math.min(100, (current / limit) * 100) : 0;
const isNearLimit = limit && current >= limit * 0.8;
const isAtLimit = limit && current >= limit;
return (
<div className={size === 'sm' ? 'space-y-1' : 'space-y-2'}>
<div className="flex justify-between text-sm">
<span className={size === 'sm' ? 'text-muted-foreground' : ''}>
{label}
</span>
<span className={isAtLimit ? 'text-destructive font-medium' : ''}>
{current}{limit ? `/${limit}` : ''} {limit === null && '(unlimited)'}
</span>
</div>
{limit && (
<Progress
value={percentage}
className={isNearLimit ? 'bg-yellow-100' : ''}
/>
)}
</div>
);
}
interface FeatureRowProps {
label: string;
enabled: boolean;
}
function FeatureRow({ label, enabled }: FeatureRowProps) {
return (
<div className="flex justify-between">
<span>{label}</span>
<span className={enabled ? 'text-green-600' : 'text-muted-foreground'}>
{enabled ? 'Included' : 'Upgrade required'}
</span>
</div>
);
}

Step 7: Create Upgrade Prompts

When users hit a limit, the error message shouldn't be a dead end. Upgrade prompts convert friction into opportunity by explaining what's blocked and offering a clear path forward. These components appear contextually throughout the application wherever limits apply.

File: apps/web/components/upgrade-prompt.tsx

'use client';
import Link from 'next/link';
import { Sparkles } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
const upgradeMessages = {
boards: {
title: 'Need more boards?',
description: 'Upgrade your plan to create additional feedback boards.',
},
feedback: {
title: 'Feedback limit reached',
description:
'This board has reached its feedback limit. Upgrade for more capacity.',
},
publicBoards: {
title: 'Public boards require a paid plan',
description:
'Share boards publicly to collect feedback from anyone.',
},
export: {
title: 'Export requires Pro or higher',
description: 'Export your feedback data for analysis and reporting.',
},
};
interface UpgradePromptProps {
feature: keyof typeof upgradeMessages;
variant?: 'card' | 'inline';
}
export function UpgradePrompt({
feature,
variant = 'inline',
}: UpgradePromptProps) {
const message = upgradeMessages[feature];
if (variant === 'card') {
return (
<Card className="border-primary/20 bg-primary/5">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{message.title}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{message.description}
</p>
<Button asChild>
<Link href="/settings/billing">View Plans</Link>
</Button>
</CardContent>
</Card>
);
}
return (
<div className="flex items-center gap-3 p-3 rounded-lg bg-primary/5 border border-primary/20">
<Sparkles className="h-4 w-4 text-primary flex-shrink-0" />
<div className="flex-1 text-sm">
<span className="font-medium">{message.title}</span>
<span className="text-muted-foreground ml-1">{message.description}</span>
</div>
<Button size="sm" asChild>
<Link href="/settings/billing">Upgrade</Link>
</Button>
</div>
);
}

Step 8: Test the Complete Flow

Billing code is notoriously easy to get wrong. Test every scenario before deploying—bugs here either lose revenue or frustrate customers.

Test Free Tier Limits

Start with a fresh organization to experience the free tier constraints:

  1. Create a new organization (starts with free tier)
  2. Create 1 board (should succeed)
  3. Try to create a 2nd board (should fail with upgrade prompt)
  4. Add 25 feedback items to the board (should succeed)
  5. Try to add a 26th item (should fail with upgrade prompt)

Test Upgrade Flow

Verify the upgrade flow unlocks additional capacity immediately:

  1. Go to /settings/billing
  2. Subscribe to Starter plan (test card: 4242 4242 4242 4242)
  3. Verify subscription is active
  4. Create more boards (up to 3)
  5. Verify feedback limit increased to 100

Test Downgrade Behavior

When users cancel, they keep their data but lose the ability to create more:

  1. Cancel subscription
  2. Existing data should remain accessible
  3. Creating new boards/feedback should be blocked at free tier limits

Common issues to watch for: Webhook events arriving out of order, subscription status caching showing stale limits, and race conditions when checking limits during concurrent requests.


Checkpoint: Verify It Works

  • [ ] Stripe configured with test keys
  • [ ] All price IDs in environment
  • [ ] Board limit enforced in server action
  • [ ] Feedback limit enforced per board
  • [ ] Usage dashboard shows current usage
  • [ ] Upgrade prompts appear at limits
  • [ ] Checkout flow works
  • [ ] Subscription status reflected in limits

Run quality checks:

pnpm healthcheck

Advanced: Lifecycle Hooks

Subscription events are opportunities for engagement. When someone subscribes, send a welcome email. When they cancel, ask for feedback (and maybe offer a discount). When their trial is ending, remind them of the value they'll lose.

These hooks let you customize behavior at key moments in the subscription lifecycle:

File: packages/billing/hooks/src/index.ts

export async function onSubscriptionCreated(event: SubscriptionEvent) {
// Welcome new subscriber - set expectations and provide onboarding
await sendEmail({
to: event.customer.email,
template: 'welcome-subscriber',
data: {
planName: event.plan.name,
},
});
}
export async function onSubscriptionCanceled(event: SubscriptionEvent) {
// Learn why they left - this feedback improves your product
await sendEmail({
to: event.customer.email,
template: 'cancellation-feedback',
});
}
export async function onTrialEnding(event: SubscriptionEvent) {
// Create urgency before they lose access
await sendEmail({
to: event.customer.email,
template: 'trial-ending',
data: {
daysRemaining: 3,
},
});
}

Beyond emails, hooks can trigger internal workflows: notifying sales when an Enterprise trial starts, logging events for analytics, or provisioning additional resources for high-tier customers.


Module 8 Complete!

You now have:

  • [x] TeamPulse-specific plan limits configured
  • [x] Board creation gated by subscription
  • [x] Feedback submission gated per board
  • [x] Usage dashboard showing current limits
  • [x] Upgrade prompts when hitting limits
  • [x] Complete checkout and subscription flow

Billing transforms TeamPulse from a free tool into a business. The patterns you've implemented—centralized limit definitions, server-side enforcement, client-side display—scale to any subscription-based SaaS. As your product evolves, you can adjust pricing tiers and limits in one place.

Next: In Module 9: Emails, you'll customize email templates and add TeamPulse-specific notifications for feedback activity.


Learn More