Billing Customization
Customize seat computation, checkout behavior, and billing UI for your business model
Adapt the billing system to your product's needs. Customize how seats are calculated at checkout, modify Stripe session parameters, extend webhook handling, and adjust UI components. This guide covers the extension points and patterns for common billing customizations.
This guide is part of the Billing & Subscriptions documentation.
Customization Overview
| What to Customize | Where | Use Case |
|---|---|---|
| Seat computation | billing-actions.service.ts | Different definition of "billable seats" |
| Checkout params | stripe-plugin.ts | Tax, metadata, address collection |
| Customer creation | stripe-plugin.ts | Custom metadata on customers |
| Webhook handling | stripe-plugin.ts / polar-plugin.ts | Additional event handling |
| UI components | packages/billing/ui/src/components/ | Pricing table, subscription cards |
| Lifecycle hooks | packages/billing/stripe/src/hooks/ | Post-event actions |
Seat-Based (Quantity) Billing
For organization billing, Makerkit charges based on seat count (subscription quantity). The default behavior:
- Seats are computed from organization member count at checkout time
- Seats have a minimum of 1
- If member count can't be determined, checkout fails to prevent underbilling
Default Seat Computation
The seat computation happens in the billing actions service:
packages/billing/ui/src/services/billing-actions.service.ts
/** * Get the number of organization members * @param organizationId - The ID of the organization * @returns The number of organization members */private async getOrganizationMemberCount(organizationId: string) { const members = await auth.api.listMembers({ headers: await headers(), query: { organizationId, }, }); // Ensure minimum 1 seat to prevent billing edge cases return Math.max(1, members.members.length);}Custom Seat Computation
To customize what counts as a "billable seat", modify the getOrganizationMemberCount method:
packages/billing/ui/src/services/billing-actions.service.ts
private async getOrganizationMemberCount(organizationId: string) { const members = await auth.api.listMembers({ headers: await headers(), query: { organizationId, }, }); // Example: Count only active users, exclude guests const billableMembers = members.members.filter( (member) => member.role !== 'guest' && member.user?.emailVerified ); // Enforce minimum of 2 for team plans return Math.max(2, billableMembers.length);}Provider Notes
| Provider | Seat Support |
|---|---|
| Stripe | Seats passed as quantity to subscription |
| Polar | Seats not supported (user-centric billing) |
Customizing Stripe Checkout
Modify checkout session parameters in the Stripe plugin.
Location
packages/billing/stripe/src/stripe-plugin.tsCommon Customizations
Tax Collection
The kit supports automatic tax via environment variables:
.env
ENABLE_AUTOMATIC_TAX_CALCULATION=trueENABLE_TAX_ID_COLLECTION=trueOr customize directly in the plugin:
packages/billing/stripe/src/stripe-plugin.ts
// In getCheckoutSessionParamsgetCheckoutSessionParams: async (_data) => { const params: Stripe.Checkout.SessionCreateParams = { // Enable automatic tax calculation (requires Stripe Tax setup) automatic_tax: { enabled: true, }, // Collect tax IDs from customers tax_id_collection: { enabled: true, }, // Billing address collection billing_address_collection: 'required', }; return { params };},Custom Metadata
Pass metadata to track checkouts:
packages/billing/stripe/src/stripe-plugin.ts
getCheckoutSessionParams: async (data) => { const params: Stripe.Checkout.SessionCreateParams = { metadata: { source: 'billing-page', referenceId: data.referenceId, }, }; return { params };},Trial Without Credit Card
Enable trials without requiring payment method:
.env
STRIPE_ENABLE_TRIAL_WITHOUT_CC=trueThis sets payment_method_collection: 'if_required' on the checkout session.
Locale
The kit automatically detects the user's locale:
packages/billing/stripe/src/stripe-plugin.ts
getCheckoutSessionParams: async (_data) => { const locale = await getCheckoutLocale(); return { params: { locale, }, };},Customizing Customer Creation
Customize metadata when Stripe creates customers.
User Customer Metadata
packages/billing/stripe/src/stripe-plugin.ts
// Customize user customer creationgetCustomerCreateParams: async (user) => { return { metadata: { userId: user.id, createdAt: new Date().toISOString(), }, };},// Callback after customer is createdonCustomerCreate: async ({ stripeCustomer, user }) => { const logger = await getLogger(); logger.info( { customerId: stripeCustomer.id, userId: user.id }, 'Stripe customer created' ); // Sync to CRM, analytics, etc.},Organization Customer Metadata
packages/billing/stripe/src/stripe-plugin.ts
organization: { enabled: true, getCustomerCreateParams: async (organization) => { return { metadata: { organizationId: organization.id, organizationSlug: organization.slug, }, }; }, onCustomerCreate: async ({ stripeCustomer, organization }) => { const logger = await getLogger(); logger.info( { customerId: stripeCustomer.id, organizationId: organization.id }, 'Stripe customer created for organization' ); },},Customizing the Billing Portal
Portal behavior is configured in Stripe Dashboard, not in code:
- Go to Stripe Dashboard → Settings → Billing → Customer portal
- Configure:
- Invoices: Show/hide invoice history
- Payment methods: Allow adding/removing cards
- Subscriptions: Allow cancel, pause, or plan changes
- Subscription cancellation: Proration, immediate vs end of period
Multi-Line Item Restrictions
If you use Advanced Pricing with multiple line items, the portal loses most functionality. Customers with multi-product subscriptions can cancel but cannot switch plans, adjust quantities, or make other modifications.
Check Advanced Pricing Limitations for workarounds.
Portal Return URL
The return URL is set in the billing actions service:
packages/billing/ui/src/actions/billing-server-actions.ts
const siteUrl = z.url().parse(process.env.NEXT_PUBLIC_SITE_URL);const basePath = '/settings/billing';const portalReturnUrl = new URL(basePath, siteUrl).href;Customizing UI Components
Billing UI Package Structure
packages/billing/ui/src/├── actions/│ └── billing-server-actions.ts├── components/│ ├── billing-portal-button.tsx│ ├── billing-portal-card.tsx│ ├── cancel-checkout-card.tsx│ ├── cancel-subscription-dialog.tsx│ ├── checkout-button.tsx│ ├── plan-picker.tsx│ ├── pricing-table.tsx│ ├── restore-subscription-dialog.tsx│ ├── subscription-card.tsx│ ├── success-checkout-card.tsx│ └── switch-plan-dialog.tsx├── loaders/│ └── billing-page.loader.ts├── schemas/│ └── billing.schema.ts└── services/ └── billing-actions.service.tsCustom Pricing Table
Modify the pricing table layout or styling:
packages/billing/ui/src/components/pricing-table.tsx
export function PricingTable({ products, selectedInterval, onIntervalChange, onSelectPlan,}: PricingTableProps) { return ( <div className="grid gap-6 md:grid-cols-3"> {products.map((product) => ( <div key={product.id} className={cn( 'rounded-xl border p-6', product.highlighted && 'border-primary shadow-lg' )} > {/* Badge */} {product.badge && ( <span className="rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary"> {product.badge} </span> )} {/* Product info */} <h3 className="mt-4 text-xl font-semibold">{product.name}</h3> <p className="mt-2 text-sm text-muted-foreground"> {product.description} </p> {/* Pricing */} <div className="mt-4"> <span className="text-3xl font-bold"> ${getActivePlan(product, selectedInterval).cost} </span> <span className="text-muted-foreground"> /{selectedInterval} </span> </div> {/* Features */} <ul className="mt-6 space-y-3"> {product.features.map((feature) => ( <li key={feature} className="flex items-center gap-2"> <CheckIcon className="h-4 w-4 text-primary" /> <span className="text-sm">{feature}</span> </li> ))} </ul> {/* CTA */} <CheckoutButton className="mt-6 w-full" planId={getActivePlan(product, selectedInterval).planId} > {product.ctaLabel ?? 'Get Started'} </CheckoutButton> </div> ))} </div> );}Custom Subscription Card
packages/billing/ui/src/components/subscription-card.tsx
export function SubscriptionCard({ subscription,}: { subscription: Subscription;}) { const statusColors: Record<string, string> = { active: 'bg-green-100 text-green-800', trialing: 'bg-blue-100 text-blue-800', past_due: 'bg-red-100 text-red-800', canceled: 'bg-gray-100 text-gray-800', }; return ( <div className="rounded-lg border p-6"> <div className="flex items-center justify-between"> <div> <h3 className="font-semibold">{subscription.plan}</h3> <p className="text-sm text-muted-foreground"> {subscription.seats && `${subscription.seats} seats • `} Renews {formatDate(subscription.periodEnd)} </p> </div> <span className={cn( 'rounded-full px-3 py-1 text-xs font-medium', statusColors[subscription.status] )} > {subscription.status} </span> </div> {subscription.cancelAtPeriodEnd && ( <div className="mt-4 rounded-md bg-yellow-50 p-3"> <p className="text-sm text-yellow-800"> Subscription will cancel on {formatDate(subscription.periodEnd)} </p> </div> )} </div> );}Extending Webhook Handling
Add custom webhook event handling beyond lifecycle hooks.
Stripe: Additional Events
The onEvent callback in the Stripe plugin handles events not covered by Better Auth:
packages/billing/stripe/src/stripe-plugin.ts
subscription: { // ... other config onEvent: async (event: Stripe.Event) => { // Payment failure handling if (event.type === 'invoice.payment_failed') { const invoice = event.data.object; // Extract subscription ID from the invoice const subscriptionRef = invoice.parent?.subscription_details?.subscription; const subscriptionId = typeof subscriptionRef === 'string' ? subscriptionRef : subscriptionRef?.id; if (subscriptionId) { await onPaymentFailed({ subscriptionId, customerId: typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id ?? '', invoiceId: invoice.id, amount: invoice.amount_due, currency: invoice.currency, attemptCount: invoice.attempt_count ?? 0, nextPaymentAttempt: invoice.next_payment_attempt ? new Date(invoice.next_payment_attempt * 1000) : null, }); } } // Add more event handlers as needed if (event.type === 'invoice.paid') { // Handle successful payment } },}Polar: Custom Event Handling
Polar webhooks are handled in the Polar plugin:
packages/billing/polar/src/polar-plugin.ts
// See packages/billing/polar/src/hooks/ for available hooks:// - on-subscription-created.ts// - on-subscription-updated.ts// - on-subscription-canceled.ts// - on-order-paid.ts// - on-customer-created.ts// - on-customer-state-changed.tsCustom Billing Page Location
The default billing page is at /settings/billing. To customize the URLs:
packages/billing/ui/src/actions/billing-server-actions.ts
const siteUrl = z.url().parse(process.env.NEXT_PUBLIC_SITE_URL);// Customize these pathsconst basePath = '/settings/billing';const cancelReturnUrl = new URL(`${basePath}?canceled=true`, siteUrl).href;const checkoutSuccessUrl = new URL(`${basePath}/success`, siteUrl).href;const checkoutCancelUrl = new URL(`${basePath}/cancel`, siteUrl).href;const portalReturnUrl = new URL(basePath, siteUrl).href;Decision Rules
Customize seat computation when:
- Billable seats differ from org members (exclude guests, count active only)
- You need minimum seat requirements beyond 1
- You have per-user licensing vs per-member
Customize checkout when:
- You need tax collection or address validation
- You want custom metadata for tracking
- You need specific trial behavior
Customize the UI when:
- Your pricing structure doesn't fit the default layout
- You need custom styling or branding
- You want different subscription card actions
If unsure: Start with defaults. Add customizations incrementally as your billing model evolves.
Common Pitfalls
- Seat computation returning 0: Always ensure minimum 1 seat. Zero seats cause checkout failures.
- Missing NEXT_PUBLIC_SITE_URL: Server-side return URLs fail without this env var.
- Modifying types without updating consumers: Billing type changes can break multiple packages. Test thoroughly.
- Forgetting Polar limitations: Seat computation and many customizations only apply to Stripe.
- Overriding webhook handlers: Don't replace existing handlers; extend them. Missing handler = missed events.
- Hardcoding URLs: Use environment variables for URLs to separate test/prod environments.
Frequently Asked Questions
How do I change what counts as a 'seat'?
Can I add a free plan?
Where do I customize the pricing table?
How do I add custom checkout fields?
Can I use different billing for personal vs org?
Next: Seat Enforcement →