Makuhari Development Corporation
13 min read, 2461 words, last updated: 2026/2/13
TwitterLinkedInFacebookEmail

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  TIMESTAMPTZ

Notice 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_limit always equals stripe_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 quantity is always 1 — you are billing for the plan, not per seat.
  • seat_limit is 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:

  1. 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.

  2. 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.

  3. Automatic expansion: automatically increment Stripe's subscription quantity when 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 fixed price_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/team UI page.
  • Add an authorization middleware that checks billing_status == active and 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.

Makuhari Development Corporation
法人番号: 6040001134259
ご利用にあたって
個人情報保護方針
個人情報取扱に関する同意事項
お問い合わせ
Copyright© Makuhari Development Corporation. All Rights Reserved.