Per-Seat Billing
Dynamic per-seat billing with automatic quantity sync in Next.js Prisma
Automatically update subscription quantities as team members join or leave organizations. Unlike seat limits (which cap membership), per-seat billing charges based on actual member count with automatic proration.
This page is part of the Billing & Subscriptions documentation. Available since version 1.1.0.
Overview
By default, the kit supports seat limits (limits.seats) which cap the number of members but don't bill dynamically. This guide shows how to implement usage-based seats where your billing provider charges based on actual member count.
Supported providers:
- Stripe: Updates subscription item quantity via
subscriptionItems.update - Polar: Updates subscription seats via
subscriptions.update
Architecture
Invitation Accepted → Hook → Update Subscription Quantity → Prorated ChargeAdmin Removes Member → Hook → Update Subscription Quantity → Prorated CreditUser Leaves Org → Hook → Update Subscription Quantity → Prorated CreditQuick Start
import { auth } from '@kit/better-auth';import { getBilling } from '@kit/billing-api';// Get active subscription and update seatsconst billing = await getBilling(auth);const { subscriptions } = await billing.listSubscriptions({ referenceId: orgId });const sub = subscriptions.find(s => s.status === 'active');await billing.updateSubscriptionQuantity({ subscriptionId: sub.providerSubscriptionId, quantity: memberCount, priceId: process.env.STRIPE_SEAT_PRICE_ID, // Stripe only});Prerequisites
- Billing provider with a per-seat price configured (recurring, quantity-based)
- Understanding of Better Auth hooks and organization policies
- Provider must support subscription updates (
supportsSubscriptionUpdates: true)
Step 1: Create Per-Seat Price
Stripe
In Stripe Dashboard:
- Create a product (e.g., "Team Seats")
- Add a recurring price with per unit pricing
- Copy the price ID (e.g.,
price_xxx)
Polar
In Polar Dashboard:
- Create a product with per-seat pricing
- Configure the seats option for the subscription
Step 2: Configure Billing Plan
Configure your plan with a per-unit price:
packages/billing/config/src/config.ts
{ id: 'pro', name: 'Pro', description: 'For growing teams', currency: 'USD', features: ['Unlimited team members', 'Priority support'], plans: [ { name: 'pro-monthly', planId: process.env.STRIPE_SEAT_PRICE_ID!, // per-unit price displayName: 'Pro Monthly', interval: 'month', cost: 10, // per seat limits: { seats: null, // No limit - usage-based }, }, ],}The subscription starts with quantity 1. Seat updates happen via billing.updateSubscriptionQuantity() (Step 3).
Step 3: Create Seat Update Service
Create a service to update subscription quantities using the provider-agnostic billing client:
apps/web/lib/billing/seat-billing.service.ts
import 'server-only';import { auth } from '@kit/better-auth';import { getBilling } from '@kit/billing-api';import { db } from '@kit/database';import { getLogger } from '@kit/shared/logger';// Your per-seat price ID (required for Stripe, optional for Polar)const SEAT_PRICE_ID = process.env.STRIPE_SEAT_PRICE_ID;export async function updateSubscriptionSeats(organizationId: string) { const logger = await getLogger(); // 1. Get current member count const memberCount = await db.member.count({ where: { organizationId }, }); const seatCount = Math.max(1, memberCount); // 2. Get billing client (provider-agnostic) const billing = await getBilling(auth); // Check if provider supports subscription updates if (!billing.capabilities.supportsSubscriptionUpdates) { logger.warn('Provider does not support subscription updates'); return; } // 3. Get active subscription via billing client const { subscriptions } = await billing.listSubscriptions({ referenceId: organizationId, }); const activeSub = subscriptions.find((sub) => ['active', 'trialing'].includes(sub.status), ); if (!activeSub?.providerSubscriptionId) { logger.info({ organizationId }, 'No active subscription found for org'); return; } // 4. Update quantity using provider-agnostic billing client try { await billing.updateSubscriptionQuantity({ subscriptionId: activeSub.providerSubscriptionId, quantity: seatCount, priceId: SEAT_PRICE_ID, // Required for Stripe, ignored for Polar prorationBehavior: 'create_prorations', }); logger.info( { organizationId, seatCount }, 'Updated subscription seats', ); } catch (error) { logger.error( { organizationId, error }, 'Failed to update subscription seats', ); throw error; }}Notes:
- The
dbimport is the Prisma client from your database package (@kit/database). - Uses
billing.listSubscriptions()to fetch subscriptions (works for both Stripe and Polar) - Stripe stores subscriptions locally; Polar fetches from API - the billing client handles both
- For Stripe multi-item subscriptions,
priceIdis required to identify which item to update - For Polar,
priceIdis ignored (seats are at subscription level)
Step 4: Hook into Member Events
Register policies in the organization policies registry to update seats on member changes.
Create the Seat Billing Policy
packages/organization/policies/src/policies/seat-billing.ts
import { definePolicy } from '@kit/policies';import type { AfterInvitationAcceptContext, AfterMemberRemoveContext,} from '../types';import { updateSubscriptionSeats } from '~/lib/billing/seat-billing.service';/** * Policy: Update seat count when invitation is accepted */export const seatBillingOnAcceptPolicy = definePolicy<AfterInvitationAcceptContext>({ name: 'seat-billing-on-accept', description: 'Updates subscription seat quantity when member joins', evaluate: async (ctx) => { await updateSubscriptionSeats(ctx.organizationId); return { allowed: true }; },});/** * Policy: Update seat count when member is removed */export const seatBillingOnRemovePolicy = definePolicy<AfterMemberRemoveContext>({ name: 'seat-billing-on-remove', description: 'Updates subscription seat quantity when member leaves', evaluate: async (ctx) => { await updateSubscriptionSeats(ctx.organizationId); return { allowed: true }; },});Register the Policies
packages/organization/policies/src/registry.ts
import { seatBillingOnAcceptPolicy, seatBillingOnRemovePolicy,} from './policies/seat-billing';// Register seat billing policiesafterInvitationAcceptRegistry.registerPolicy(seatBillingOnAcceptPolicy);afterMemberRemoveRegistry.registerPolicy(seatBillingOnRemovePolicy);The afterMemberRemove hook fires for both:
- Admin removing a member
- User voluntarily leaving the organization
Step 5: Initial Seats at Checkout
Pass the current member count as seats at checkout time:
const memberCount = await getOrganizationMemberCount(organizationId);await billing.checkout({ userId, planId: 'pro-monthly', referenceId: organizationId, seats: memberCount, // Set initial quantity successUrl: '/billing/success', cancelUrl: '/billing/cancel',});This sets the subscription's initial quantity. After checkout, the seat update service (Step 3) handles adjustments when members join/leave.
Proration Options
Control how billing providers handle mid-cycle changes:
await billing.updateSubscriptionQuantity({ subscriptionId: activeSub.providerSubscriptionId, quantity: seatCount, priceId: SEAT_PRICE_ID, prorationBehavior: 'create_prorations', // default});| Behavior | Description |
|---|---|
create_prorations | Charge/credit prorated amount (default) |
always_invoice | Invoice immediately |
none | No proration (Stripe only, Polar falls back to prorate) |
Edge Cases
Pending Invitations
Decide whether pending invitations should count toward seats:
- Yes: Update seats when invitation is sent
- No: Update seats only when invitation is accepted (recommended)
Free Tier / Included Seats
If your plan includes N free seats:
const FREE_SEATS_INCLUDED = 3;const billableSeats = Math.max(0, memberCount - FREE_SEATS_INCLUDED);Minimum Seats
Both Stripe and Polar require quantity >= 1. The service already handles this:
const seatCount = Math.max(1, memberCount);Testing
- Create a test organization with a per-seat subscription
- Add a member → verify subscription quantity increases in your billing provider
- Remove a member → verify quantity decreases
- Check your billing provider dashboard for proration invoices
Webhook Considerations
If seats are modified outside your application (e.g., admin changes quantity in provider dashboard), the billing client's listSubscriptions() will return the updated count automatically since it queries the provider directly.
For Stripe (which stores subscriptions locally), Better Auth's webhook handlers sync data to the database automatically. No additional webhook handling is needed for seat syncing.
Common Pitfalls
- Using seat limits instead of usage-based: This guide implements dynamic billing. If you just want to cap members, use
limits.seatsin your billing config instead. - Forgetting minimum seat requirement: Both Stripe and Polar require quantity >= 1. Always use
Math.max(1, count). - Missing
priceIdfor Stripe multi-item: When a subscription has multiple prices, you must specify which price to update. - Not handling policy errors: The policy should log errors but return
{ allowed: true }to avoid blocking member operations. - Counting pending invitations: Recommended to only count accepted members to avoid billing for users who never join.
Frequently Asked Questions
What's the difference between seat limits and usage-based seats?
Do I need to handle both add and remove?
Can I combine this with seat enforcement?
Related docs:
- Advanced Pricing - Multi-line item checkout with per-seat pricing
- Seat Enforcement - Cap members at purchased seat limit
- Customization - Customize seat computation at checkout
- Providers - Provider capabilities comparison
- Organization Lifecycle Hooks - Complete guide to after hooks for organization events