Seat-based licensing enforcement: how to count and enforce org members

Seat-based pricing is the most common model for B2B SaaS, but implementing it correctly has more edge cases than it appears. The question "how many seats does this organization have?" sounds like a simple count — it is not. Pending invitations, SSO auto-provisioned users, deactivated members who retain data, service accounts, and billing cycle timing all affect the count. Getting it wrong means either over-charging customers (which generates support tickets and churn) or allowing usage beyond the purchased limit (which costs you revenue).

What counts as a seat?

The first decision is definitional. A seat should correspond to one human user who actively uses the product. Service accounts, API clients, and integration users often should not count as seats. Common edge cases:

  • Pending invitations: the person has not accepted yet and has never logged in. Most products count these, since the invite was sent and is consuming a seat allocation from the admin's perspective. Expire the invite after 7–30 days if not accepted.
  • SSO-provisioned users: automatically created when a user's IdP asserts them. Count immediately on provisioning, not on first login — the customer's IdP already considers them an active member.
  • Deactivated/suspended users: policy varies. Most products do not count deactivated users but retain their data. Make this explicit in your pricing page.
  • Guest/viewer roles: many products have unlimited guests with limited permissions. Whether guests count as seats is a pricing decision that must be implemented consistently.
-- Accurate seat count query
SELECT COUNT(*) AS active_seats
FROM org_members om
WHERE om.org_id = $1
  AND om.status IN ('active', 'pending_invite')  -- pending invites count
  AND om.role != 'guest'                          -- guests don't count
  AND om.is_service_account = FALSE;              -- service accounts don't count

-- For seat limit enforcement, use a FOR UPDATE lock to prevent race conditions
-- when multiple concurrent invites/provisions might exceed the limit
SELECT
  o.seat_limit,
  (SELECT COUNT(*) FROM org_members
   WHERE org_id = o.id
     AND status IN ('active', 'pending_invite')
     AND role != 'guest'
     AND is_service_account = FALSE) AS current_seats
FROM organizations o
WHERE o.id = $1
FOR UPDATE;

Enforcement point: invite vs token issuance

There are two reasonable places to enforce seat limits: at the point of invite/provision (preventive) and at the point of token issuance (reactive). Most products should enforce at invite time. Enforcing only at token issuance means a user can receive an invite, accept it, and be blocked at login — a confusing and frustrating experience.

// Enforce at invite creation
async function createInvitation(orgId, inviteeEmail, invitedByUserId, role) {
  await db.query('BEGIN');
  try {
    const { rows } = await db.query(`
      SELECT
        o.seat_limit,
        COUNT(om.user_id) FILTER (
          WHERE om.status IN ('active', 'pending_invite')
            AND om.role != 'guest'
            AND om.is_service_account = FALSE
        ) AS used_seats
      FROM organizations o
      LEFT JOIN org_members om ON om.org_id = o.id
      WHERE o.id = $1
      GROUP BY o.seat_limit
      FOR UPDATE OF o
    `, [orgId]);

    const { seat_limit, used_seats } = rows[0];

    if (seat_limit !== null && parseInt(used_seats) >= seat_limit) {
      await db.query('ROLLBACK');
      return {
        success: false,
        error: 'seat_limit_reached',
        limit: seat_limit,
        used: used_seats,
      };
    }

    // Insert invitation within the same transaction
    const inviteToken = crypto.randomBytes(32).toString('base64url');
    const tokenHash = createHash('sha256').update(inviteToken).digest('hex');

    await db.query(`
      INSERT INTO invitations (org_id, invitee_email, invited_by, role, token_hash, expires_at)
      VALUES ($1, $2, $3, $4, $5, NOW() + INTERVAL '7 days')
    `, [orgId, inviteeEmail, invitedByUserId, role, tokenHash]);

    await db.query('COMMIT');
    return { success: true, inviteToken };

  } catch (err) {
    await db.query('ROLLBACK');
    throw err;
  }
}
Use FOR UPDATE on the organization row when reading seat counts for enforcement. Without locking, two concurrent invite requests can both read "9/10 seats used" and both succeed, leaving you at 11/10 seats. PostgreSQL row-level locking handles this correctly; your application logic must use it.

Handling SSO SCIM provisioning

Enterprise customers using SCIM to auto-provision users expect the provisioning to succeed — their IdP is authoritative. Blocking SCIM provisioning because the seat limit is reached creates a confusing failure mode: the IdP thinks the user was created, but your system disagrees. The better approach is to allow SCIM provisioning to succeed but mark over-limit users as provisioned but not activated, and notify the org admin that they need to purchase more seats.

// SCIM provision handler
async function handleSCIMProvision(orgId, scimUser) {
  const seatStatus = await checkSeatAvailability(orgId);

  const memberStatus = seatStatus.hasCapacity ? 'active' : 'over_limit';

  // Always create the user record (SCIM must not fail silently)
  const user = await createOrUpdateUser({
    email: scimUser.userName,
    orgId,
    status: memberStatus,
    scimId: scimUser.id,
  });

  if (memberStatus === 'over_limit') {
    // Notify org admin — do not block the SCIM response
    await sendSeatLimitAlert(orgId, user.id);
  }

  // Return success to IdP regardless
  return { id: user.id, status: 200 };
}

// At token issuance: over-limit users can log in but see an upgrade prompt
async function issueToken(userId, orgId) {
  const member = await getOrgMember(userId, orgId);

  if (member.status === 'over_limit') {
    // Allow login but include a claim that triggers the upgrade UI
    return issueAccessToken(userId, orgId, {
      seat_over_limit: true,
      // Optional: restrict permissions for over-limit users
    });
  }

  return issueAccessToken(userId, orgId);
}

Grace periods and prorating

When a customer downgrades their plan mid-cycle (reducing from 25 seats to 10), you need a grace period before enforcing the new limit. Immediately deactivating 15 members when they click "downgrade" is hostile UX. The typical pattern: show a warning ("You have 25 members but your new plan allows 10 — please deactivate 15 members before your next billing date"), send reminder emails as the cycle end approaches, and only hard-enforce at the start of the next billing period.

The inverse — upgrading mid-cycle to add seats — should take effect immediately. The customer paid for the new capacity; they should get it. Prorating means charging for the remaining days in the cycle for the additional seats, not requiring them to wait until the next cycle.

Build seat limit changes as an event that is applied at a specific effective date, not immediately overwriting the current limit. This gives you a clean model for grace periods and future-dated downgrades without complex conditional logic in the enforcement queries.

← Back to blog Try Bastionary free →