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: Auto-syncs via
seatPriceId(recommended) or manual updates viasubscriptionItems.update - Polar: Updates subscription seats via
subscriptions.update
Automatic Seat Sync (Recommended)
Since version 1.3.0, Better Auth's Stripe plugin handles seat syncing automatically when you set seatPriceId on your plan. No custom hooks, services, or policies required.
Member Joins Org → Better Auth hook → Stripe quantity updated → Prorated chargeMember Leaves Org → Better Auth hook → Stripe quantity updated → Prorated creditHow It Works
When seatPriceId is set on a StripePlan:
- At checkout: Better Auth creates two line items — the base plan (quantity 1) and a seat line item (
seatPriceId, quantity = current member count) - On member add: Better Auth counts org members and updates the Stripe subscription seat item quantity
- On member remove: Same — quantity decreases automatically
- Proration: Uses
create_prorationsby default
Step 1: Create Per-Seat Prices in Stripe
In Stripe Dashboard, for each billing interval:
- Create a product (e.g., "Team Seat")
- Add a recurring per-unit price for monthly (e.g.,
price_seat_monthly) - Add a recurring per-unit price for yearly (e.g.,
price_seat_yearly)
Stripe rejects mixed-interval checkout sessions. You need a separate seat price for each billing interval (monthly seat price for monthly plans, yearly seat price for yearly plans).
Step 2: Configure seatPriceId in Billing Config
Add seatPriceId to each plan that uses per-seat billing:
packages/billing/config/src/config.ts
{ id: 'pro', name: 'Pro', description: 'For growing teams', currency: 'USD', features: ['Per-seat billing', 'Priority support'], plans: [ { name: 'pro-monthly', planId: process.env.STRIPE_PRICE_PRO_MONTHLY!, seatPriceId: process.env.STRIPE_SEAT_PRICE_PRO_MONTHLY, displayName: 'Pro Monthly', interval: 'month', cost: 19.99, // base price limits: { seats: 10 }, }, { name: 'pro-yearly', planId: process.env.STRIPE_PRICE_PRO_YEARLY!, seatPriceId: process.env.STRIPE_SEAT_PRICE_PRO_YEARLY, displayName: 'Pro Yearly', interval: 'year', cost: 199.99, limits: { seats: 10 }, }, ],}That's it. No services, hooks, or policies to write. Better Auth handles everything internally.
Environment Variables
# Base plan pricesSTRIPE_PRICE_PRO_MONTHLY=price_...STRIPE_PRICE_PRO_YEARLY=price_...# Per-seat prices (one per interval)STRIPE_SEAT_PRICE_PRO_MONTHLY=price_...STRIPE_SEAT_PRICE_PRO_YEARLY=price_...Seat-Only Plans
If your plan is entirely per-seat (no base fee), set seatPriceId equal to planId:
{ name: 'team-monthly', planId: process.env.STRIPE_SEAT_PRICE_MONTHLY!, seatPriceId: process.env.STRIPE_SEAT_PRICE_MONTHLY, interval: 'month', cost: 10, // per seat}This creates a single line item at checkout instead of base + seat.
Requirements
- Better Auth Stripe plugin with
organization: { enabled: true }(already configured in the kit) seatPriceIdis Stripe-only; Polar does not support this field (use manual approach below)
What About limits.seats?
limits.seats and seatPriceId serve different purposes:
| Feature | limits.seats | seatPriceId |
|---|---|---|
| Purpose | Cap member count | Auto-sync billing quantity |
| Enforcement | SeatEnforcementService | Stripe subscription |
| Billing effect | None | Charges per seat |
You can use both together: limits.seats prevents inviting beyond a threshold, while seatPriceId ensures Stripe charges for actual members.
Manual Seat Sync (Alternative)
For Polar, or when you need custom seat logic (free included seats, custom proration, etc.), use the manual approach.
Architecture
Invitation Accepted → Policy → Update Subscription Quantity → Prorated ChargeAdmin Removes Member → Policy → Update Subscription Quantity → Prorated CreditUser Leaves Org → Policy → 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: Create Seat Update Service
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 3: 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 4: 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 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.
- Mixed-interval seat prices: When using
seatPriceId, use a separate seat price per interval (monthly/yearly). Stripe rejects mixed-interval checkout sessions.
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?
Should I use seatPriceId or manual hooks?
Related docs:
- Billing Configuration - Plan and product setup
- 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