Per-Seat Billing

Implement dynamic per-seat billing with automatic quantity updates

Per-seat billing automatically updates subscription quantities when team members join or leave an organization. Unlike seat limits (which cap members), usage-based seats charge based on actual member count with automatic proration.

This guide is part of the Billing & Subscriptions documentation. Introduced in 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 Charge
Admin Removes Member → Hook → Update Subscription Quantity → Prorated Credit
User Leaves Org → Hook → Update Subscription Quantity → Prorated Credit

Quick Start

import { auth } from '@kit/better-auth';
import { getBilling } from '@kit/billing-api';
// Get active subscription and update seats
const 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

  1. Billing provider with a per-seat price configured (recurring, quantity-based)
  2. Understanding of Better Auth hooks and organization policies
  3. Provider must support subscription updates (supportsSubscriptionUpdates: true)

Step 1: Create Per-Seat Price

Stripe

In Stripe Dashboard:

  1. Create a product (e.g., "Team Seats")
  2. Add a recurring price with per unit pricing
  3. Copy the price ID (e.g., price_xxx)

Polar

In Polar Dashboard:

  1. Create a product with per-seat pricing
  2. 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, member } from '@kit/database';
import { eq, count } from 'drizzle-orm';
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
.select({ count: count() })
.from(member)
.where(eq(member.organizationId, organizationId));
const seatCount = Math.max(1, memberCount?.count ?? 1);
// 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 db and member imports are from your Drizzle database package (@kit/database). Adjust the import path if your schema exports differ.
  • 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, priceId is required to identify which item to update
  • For Polar, priceId is 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 policies
afterInvitationAcceptRegistry.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
});
BehaviorDescription
create_prorationsCharge/credit prorated amount (default)
always_invoiceInvoice immediately
noneNo 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?.count ?? 1);

Testing

  1. Create a test organization with a per-seat subscription
  2. Add a member → verify subscription quantity increases in your billing provider
  3. Remove a member → verify quantity decreases
  4. 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.seats in your billing config instead.
  • Forgetting minimum seat requirement: Both Stripe and Polar require quantity >= 1. Always use Math.max(1, count).
  • Missing priceId for 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?
Seat limits (limits.seats) cap member count but don't affect billing. Usage-based seats dynamically update the subscription quantity and billing amount.
Do I need to handle both add and remove?
Yes. Register policies for both afterInvitationAccept (member joins) and afterMemberRemove (member leaves or removed).
Can I combine this with seat enforcement?
Not typically. Seat enforcement caps members at purchased seats. Usage-based billing charges for actual members without capping.

Related docs: