Introduction
Seat-based billing is one of the most common monetization models in B2B SaaS. Yet the implementation details — how seats are counted, what happens when a user accepts an invite while another does the same, how to keep Stripe's quantity synchronized with your database — are rarely explained end-to-end. Most tutorials stop at "create a Checkout session," leaving you to figure out the operational complexity yourself.
This post walks through a complete seat system design for a SaaS product using Clerk (for authentication and organization management) and Stripe (for billing). We'll cover the full spectrum: from data modeling and webhook handling to invitation flows, concurrency pitfalls, and the pragmatic choice between simple fixed-seat plans versus dynamic quantity billing.
Background and Context
What Is a "Seat" in SaaS?
Before writing a single line of code, it is worth being precise about what a seat actually represents. The most common definition is:
One seat = one available member slot within an organization (team).
The billing model that follows from this definition is typically:
- An organization is the billing entity (not an individual user).
- The organization subscribes to a plan that grants a certain number of seats.
- Each active member of the organization consumes one seat.
- Inviting a new member requires an available seat.
This org-centric model is the standard for B2B SaaS because it mirrors how businesses actually purchase software: a company buys a plan, not each employee individually.
Why Clerk + Stripe?
Clerk provides first-class Organizations support: creating teams, inviting members, managing roles, and listing memberships are all covered by its API. This saves significant backend work.
Stripe provides the billing layer: products, prices, subscriptions, webhooks, and a hosted Billing Portal for self-service upgrades and downgrades.
The integration challenge is keeping the two systems synchronized — specifically, ensuring that your database accurately reflects the subscription state from Stripe and the membership state from Clerk, so that seat enforcement is reliable.
Core Concepts
1. The Separation of Concerns
A clean mental model is to assign each system a clear responsibility:
| System | Responsibility |
|---|---|
| Clerk | Organization identity, memberships, roles, invitations |
| Stripe | Payment, subscription lifecycle, plan enforcement |
| Your database | The bridge: seat limits, billing status, plan metadata |
Your database is not the source of truth for either memberships (that's Clerk) or payments (that's Stripe). It is the join table that gives your application a single place to query both dimensions together.
2. The Data Model
A minimal schema that is sufficient for most early-stage SaaS products:
orgs table
id UUID PRIMARY KEY
clerk_org_id TEXT UNIQUE NOT NULL
owner_user_id TEXT
plan TEXT -- 'free' | 'pro' | 'business'
seat_limit INT -- derived from plan, not from Stripe quantity
stripe_customer_id TEXT
stripe_subscription_id TEXT
billing_status TEXT -- 'active' | 'past_due' | 'canceled'org_invites table (recommended even when using Clerk's built-in invitations)
id UUID PRIMARY KEY
org_id UUID REFERENCES orgs(id)
email TEXT NOT NULL
role TEXT
status TEXT -- 'pending' | 'accepted' | 'expired' | 'revoked'
token TEXT UNIQUE
expires_at TIMESTAMPTZ
created_at TIMESTAMPTZNotice that seat_limit lives in your database and comes from your own plan configuration — not from Stripe's quantity field. This is a deliberate choice explained below.
3. Fixed Seats vs. Dynamic Quantity
There are two broad approaches to seat billing:
Dynamic quantity (fully metered)
seat_limitalways equalsstripe_subscription_item.quantity.- Every time a member joins or leaves, you call the Stripe API to update
quantity. - Stripe prorates charges automatically.
- This is accurate but operationally complex: frequent API calls, edge cases around proration timing, and webhook noise.
Fixed seats per plan (recommended for early stage)
- Each plan has a hard-coded seat limit: Free = 1, Pro = 5, Business = 20.
- Stripe's
quantityis always 1 — you are billing for the plan, not per seat. seat_limitis a value in your own code or configuration table, never read from Stripe.- To get more seats, a customer upgrades their plan.
The fixed-seat model eliminates an entire category of complexity. You do not need to synchronize quantity, handle proration edge cases, or decide what happens when two users join simultaneously and both trigger a quantity increment. The tradeoff is coarser pricing granularity, which is perfectly acceptable for most products until they have strong enterprise demand.
System Design in Detail
Webhook State Machine
The webhook handler is the backbone of your billing integration. These are the Stripe events you must handle, and what each one should do:
| Event | Action |
|---|---|
checkout.session.completed |
Create or update orgs record: set stripe_customer_id, stripe_subscription_id, plan, billing_status = active |
customer.subscription.updated |
Re-read plan field (in case of upgrade/downgrade); update billing_status |
customer.subscription.deleted |
Set billing_status = canceled; optionally lock the org to free tier |
invoice.paid |
Set billing_status = active (recovers from past_due) |
invoice.payment_failed |
Set billing_status = past_due; trigger grace-period logic |
A critical rule: always use the webhook to update your database, never the frontend. After a Checkout session completes, redirect the user to a success page, but do not trust the URL parameters to update billing state. Wait for the webhook.
// Minimal webhook handler (Next.js API route)
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response('Invalid signature', { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await db.orgs.upsert({
where: { stripe_customer_id: session.customer as string },
update: {
stripe_subscription_id: session.subscription as string,
plan: 'pro',
billing_status: 'active',
},
});
break;
}
case 'invoice.paid': {
const invoice = event.data.object as Stripe.Invoice;
await db.orgs.updateMany({
where: { stripe_customer_id: invoice.customer as string },
data: { billing_status: 'active' },
});
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await db.orgs.updateMany({
where: { stripe_customer_id: invoice.customer as string },
data: { billing_status: 'past_due' },
});
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
await db.orgs.updateMany({
where: { stripe_subscription_id: sub.id },
data: { billing_status: 'canceled', plan: 'free' },
});
break;
}
}
return new Response('OK', { status: 200 });
}The Invitation Flow
The invitation flow is where most of the product complexity lives. The key design question is: who "owns" the invite?
Clerk's built-in invitation API handles the low-level mechanics: sending an email, generating a secure token, and adding the user to the organization upon acceptance. This is reliable and saves you from building an email delivery system from scratch.
Your backend owns the business logic: checking seat availability before issuing an invite, recording the invite in your database for display purposes, and enforcing limits at the moment of acceptance.
A recommended invitation endpoint:
// POST /api/orgs/:orgId/invites
export async function inviteMember(orgId: string, email: string, role: string, requestingUserId: string) {
const org = await db.orgs.findUnique({ where: { id: orgId } });
// 1. Check billing status
if (org.billing_status !== 'active') {
throw new Error('Subscription is not active');
}
// 2. Check requester permissions
const membership = await clerkClient.organizations.getOrganizationMembership({
organizationId: org.clerk_org_id,
userId: requestingUserId,
});
if (!['admin', 'owner'].includes(membership.role)) {
throw new Error('Insufficient permissions');
}
// 3. Check seat availability (active members + pending invites)
const activeCount = await clerkClient.organizations
.getOrganizationMembershipList({ organizationId: org.clerk_org_id })
.then(list => list.totalCount);
const pendingCount = await db.org_invites.count({
where: { org_id: orgId, status: 'pending' },
});
if (activeCount + pendingCount >= org.seat_limit) {
throw new Error('Seat limit reached');
}
// 4. Issue the invite via Clerk
await clerkClient.organizations.createOrganizationInvitation({
organizationId: org.clerk_org_id,
emailAddress: email,
role,
});
// 5. Record locally for display
await db.org_invites.create({
data: { org_id: orgId, email, role, status: 'pending' },
});
}Counting Seats: Two Schools of Thought
Active-only counting: seat_used = count(active members). Pending invites do not consume a seat until accepted.
- Advantage: intuitive — you pay for people who are actually using the product.
- Disadvantage: a race condition exists where two people accept invitations simultaneously and both succeed, pushing the org over its limit.
Active + pending counting: seat_used = count(active members) + count(pending invites). An invite immediately reserves a slot.
- Advantage: no possibility of exceeding the limit.
- Disadvantage: unaccepted invites block slots; you need an expiry mechanism to release them.
Recommendation: use active + pending counting for simplicity and correctness. Set invitations to expire after 7 days and run a background job (or handle it lazily on the next invite check) to mark expired invites as expired and free up their slots.
Handling Over-Limit Scenarios
There are three common strategies when a seat limit would be breached:
-
Hard block: reject the action outright. Tell the user to upgrade first. This is the simplest and least error-prone option for early-stage products.
-
Soft limit with feature degradation: allow the overage but mark the org as
over_limit = true, then disable specific features (new data creation, API calls, etc.) until the org is brought back within limits or upgrades. -
Automatic expansion: automatically increment Stripe's subscription
quantitywhen the seat count increases. This is the most frictionless experience for customers but requires careful implementation to avoid double-billing and webhook loops.
For a fixed-seat model, only option 1 applies cleanly. Implement options 2 or 3 when you have moved to dynamic quantity billing and have the engineering capacity to handle it reliably.
Team Management UI
One point that often surprises founders: Clerk does not provide a team management UI to your customers. Clerk provides APIs and authentication components. The "Team Settings" page — where a member can invite colleagues, revoke invitations, change roles, and see seat usage — is something you build in your product.
This is not a gap in Clerk; it is by design. Every SaaS product has different UX requirements for team management. The good news is that the page is straightforward to build and covers most early-stage needs with minimal code.
A minimal /settings/team page should display:
- Seat usage: "3 / 5 seats used" (read from Clerk membership count + your
seat_limit). - Active members table: name, email, role, with "Remove" and "Change role" actions.
- Pending invites table: email, role, invited date, with "Revoke" and "Resend" actions.
- Invite form: email input + role selector + "Send Invite" button, disabled when seats are full.
All actions route through your backend (not directly to Clerk from the browser), so you can enforce seat checks and maintain your local org_invites record.
Handling Subscription Lifecycle Events
Past Due
When invoice.payment_failed fires, set billing_status = past_due in your database. Do not immediately cut off access — this creates a terrible customer experience for transient payment failures. Instead:
- Immediately: send a notification or display an in-app banner.
- After N days (typically 3–7): restrict write operations or disable non-essential features.
- After subscription is deleted: enforce full downgrade to free tier.
Stripe's own subscription retry schedule usually gives 3–4 payment attempts over several days, so your in-app grace period should align with this.
Cancellation
When a subscription is deleted, decide what happens to the organization's data and members. The recommended approach:
- Preserve all data (never delete customer data on cancellation).
- Downgrade the org to the free plan (seat_limit = 1).
- Do not forcefully remove members — instead, block all org operations that require more than the free seat limit until the member count is naturally reduced.
The Minimum Viable Implementation Checklist
For a team building this from scratch, here is a sequenced implementation plan:
- Enable Clerk Organizations and configure roles (owner / admin / member).
- Create a Stripe product with a fixed price per plan tier.
- Build
POST /billing/checkout— creates a Stripe Checkout Session with a fixedprice_id. - Build
POST /billing/webhook— handles the five events listed above. - Build
POST /orgs/:orgId/invites— issues invitations with seat check. - Build
DELETE /orgs/:orgId/invites/:inviteId— revokes pending invitations. - Build
DELETE /orgs/:orgId/members/:userId— removes active members. - Build
/settings/teamUI page. - Add an authorization middleware that checks
billing_status == activeand the user's membership role on every protected API route.
This set of components closes the loop: users can subscribe, invite teammates, manage their team, and you have the billing lifecycle fully handled.
Implications and Best Practices
Keep Stripe as the billing source of truth, Clerk as the membership source of truth. Never derive seat counts from your local database's member cache; always query Clerk's membership list (or rely on Clerk webhooks to keep a local cache fresh). Never derive billing status from your local database in isolation; always re-sync from Stripe webhooks.
Treat webhooks as the only reliable sync mechanism. Polling Stripe or Clerk to reconcile state is fragile. Design your system so that every state transition is driven by an inbound webhook event.
Build your invite flow through your backend. Allowing the browser to call Clerk's invitation API directly bypasses your seat checks. Every invite action must pass through your server where you can enforce business rules atomically.
Do not skip the past_due grace period. Immediately disabling access on payment failure angers customers who have a temporary card issue. A 3-day grace window with an in-app banner is the industry standard and dramatically reduces churn from payment failures.
Start with fixed plans, not dynamic quantity. The engineering cost of reliable per-seat quantity synchronization is significant. Fixed plans ship faster, are easier to reason about, and serve most early-stage products well. Migrate to dynamic quantity only when customer demand clearly requires it.
Conclusion
A seat system built on Clerk and Stripe does not need to be complex from day one. The most important architectural decision is to separate responsibilities cleanly: Clerk owns identity and memberships, Stripe owns the billing lifecycle, and your database is the thin bridge that makes them queryable together.
The fixed-seat approach — hard-coding seat limits per plan and relying on plan upgrades rather than quantity changes — eliminates an entire class of synchronization problems and is the right starting point for the vast majority of SaaS products.
The invitation flow, often underestimated, is where real product experience is built. Implement it server-side, count both active members and pending invites against your seat limit, and surface clear feedback in a Team Settings page that you build yourself. Clerk gives you the primitives; the product experience is yours to design.
With these foundations in place, adding dynamic quantity billing, SCIM provisioning, or SSO is a matter of extending the existing model rather than redesigning it.
