Guide

Per-Seat Licensing in SaaS Applications: Architecture, Pitfalls, and Implementation

TOT
Traffic Orchestrator Team
Product Engineering
May 22, 2026 14 min read 3,229 words
Share

Per-seat licensing — charging customers based on the number of named users who access your software — is the dominant pricing model in SaaS. Salesforce, Slack, Figma, GitHub, and most B2B tools rely on it. The concept is straightforward: more users, more revenue. But the implementation? That's where teams stumble. Ghost seats inflate bills, SSO directory sync drifts out of alignment, and overage policies either anger customers or leak revenue. This guide covers the architecture, code, and strategy you need to get per-seat licensing right.

What Per-Seat Licensing Is (and Why It Won)

Per-seat (also called per-user or named-user) licensing assigns a license to each individual who accesses the software. Unlike concurrent/floating licenses — where 50 people share 10 seats — per-seat means every user gets their own seat, whether they log in daily or once a quarter.

It dominates SaaS for three reasons:

  • Revenue predictability — seat count directly correlates with headcount, making forecasting straightforward for both vendor and buyer
  • Simple mental model — buyers understand "we have 200 employees, we need 200 seats" without calculating peak concurrency
  • Natural expansion revenue — as customers grow their teams, revenue grows automatically without renegotiating contracts

The model works best when your software delivers value per individual user — collaboration tools, development platforms, design applications, project management, and communication products all fit naturally.

Implementation Architecture

A production-grade per-seat system involves more than a user_count column. You need a reliable way to count seats, synchronize with identity providers, and handle the full provisioning lifecycle.

Core Data Model

At minimum, you need three entities working together:

// Simplified schema for per-seat licensing
// Organization: the billing entity
interface Organization {
  id: string
  name: string
  planId: string
  maxSeats: number          // contractual seat limit
  seatOveragePolicy: 'hard_cap' | 'soft_cap' | 'grace_period'
}

// Seat: the assignment record
interface Seat {
  id: string
  orgId: string
  userId: string
  assignedAt: string        // ISO timestamp
  lastActiveAt: string      // for ghost seat detection
  source: 'manual' | 'sso' | 'scim' | 'api'
  status: 'active' | 'suspended' | 'pending_removal'
}

// SeatSnapshot: for audit and billing
interface SeatSnapshot {
  id: string
  orgId: string
  activeSeats: number
  capturedAt: string
  billingPeriodStart: string
  billingPeriodEnd: string
}

The Seat table is the source of truth for who occupies a seat. The SeatSnapshot table provides historical records for billing reconciliation and compliance audits. Separating the two prevents the "we had 50 users on invoice day but only 30 normally" disputes.

Counting Seats Correctly

This sounds trivial until you encounter edge cases. The seat count query must handle:

  • Deactivated users who haven't been deprovisioned — SSO marks them inactive, but the seat record still exists
  • Pending invitations — does an invited-but-not-yet-accepted user consume a seat? Most vendors say yes
  • Service accounts and API-only users — bots and CI runners shouldn't consume human seats
  • Users in multiple organizations — a consultant might be in three customer orgs, consuming one seat each
// Accurate seat count query
const getActiveSeatCount = async (orgId: string): Promise<number> => {
  const result = await db
    .select({ count: count() })
    .from(seats)
    .where(
      and(
        eq(seats.orgId, orgId),
        eq(seats.status, 'active'),
        ne(seats.userType, 'service_account')  // exclude bots
      )
    )

  return result[0]?.count ?? 0
}

SSO and IdP Synchronization

Enterprise customers don't manually add users — they connect their identity provider (Okta, Azure AD, Google Workspace) and expect seats to synchronize automatically. SCIM (System for Cross-domain Identity Management) is the standard protocol for this.

The sync lifecycle works like this:

  1. User created in IdP — SCIM POST triggers seat provisioning. Check seat availability before confirming.
  2. User updated in IdP — SCIM PATCH updates seat metadata (name, email, group membership). No seat count change.
  3. User deactivated in IdP — SCIM PATCH with active: false triggers seat release. This is where most implementations fail — they deactivate the user but forget to release the seat.
  4. User deleted in IdP — SCIM DELETE permanently releases the seat and archives the assignment record.
// SCIM webhook handler for user deprovisioning
const handleScimDeprovision = async (event: ScimEvent) => {
  const { userId, orgId } = event

  // 1. Mark the seat as pending removal (grace period)
  await db
    .update(seats)
    .set({
      status: 'pending_removal',
      lastActiveAt: new Date().toISOString()
    })
    .where(
      and(
        eq(seats.userId, userId),
        eq(seats.orgId, orgId)
      )
    )

  // 2. Schedule actual seat release after 72h grace period
  //    (allows IT admins to reverse accidental deprovisioning)
  await scheduleJob('release-seat', {
    userId,
    orgId,
    executeAt: Date.now() + 72 * 60 * 60 * 1000
  })

  // 3. Notify billing system of the pending change
  await emitEvent('seat.pending_release', { userId, orgId })
}

Common Pitfalls

These three issues silently erode customer trust and your revenue. Every per-seat system hits them eventually.

Ghost Seats

Ghost seats are assigned to users who no longer use the product. The employee left six months ago, nobody deprovisioned them, and the customer is still paying. At scale, 15–25% of seats in large organizations are ghosts.

Detection is straightforward — compare lastActiveAt against a threshold:

// Find ghost seats (no activity in 90 days)
const ghostSeats = await db
  .select()
  .from(seats)
  .where(
    and(
      eq(seats.orgId, orgId),
      eq(seats.status, 'active'),
      lt(seats.lastActiveAt, ninetyDaysAgo)
    )
  )

// Notify org admins, don't auto-remove
await sendGhostSeatReport(orgId, ghostSeats)

The fix isn't automatic removal — customers hate surprises. Instead, surface ghost seat reports in your admin dashboard and let org admins decide. Some vendors offer "seat reclamation" features that automatically suspend inactive seats and notify the user before final removal.

Seat Hoarding

The opposite of ghost seats. Managers pre-purchase more seats than needed "just in case" for future hires. This inflates your reported seat count but creates churn risk — when procurement reviews the license, they'll downgrade aggressively.

Combat this with:

  • Usage analytics visible to admins — show utilization rate (active seats / total seats) prominently
  • Right-sizing recommendations — "Your team uses 73 of 100 seats. Reducing to 80 seats saves $2,700/year."
  • Just-in-time provisioning — instead of pre-buying seats, let customers add seats on-demand with prorated billing

Shadow Users

Users who access your product through shared credentials, bypassing the seat model entirely. One license key passed around a 10-person team. This is revenue leakage — your product serves 10 users but bills for 1.

Mitigate with:

  • Concurrent session limits — restrict each seat to one active session at a time
  • Device fingerprinting — flag when a single seat appears on 5+ unique devices in a week
  • SSO enforcement — require SSO login, which ties access to individual identity
  • Behavioral anomaly detection — a single user making API calls from 4 different IP ranges simultaneously is likely shared

Overage Handling Strategies

What happens when a customer hits their seat limit and tries to add user #101 on a 100-seat plan? Your overage policy determines both customer experience and revenue impact.

Hard Cap

Strict enforcement — reject the provisioning request until seats are freed or the plan is upgraded. This is the simplest to implement but creates the worst user experience. An IT admin provisioning new hires at 9 AM Monday doesn't want to deal with license negotiations.

// Hard cap implementation
const provisionSeat = async (orgId: string, userId: string) => {
  const org = await getOrganization(orgId)
  const currentSeats = await getActiveSeatCount(orgId)

  if (currentSeats >= org.maxSeats) {
    return {
      success: false,
      error: 'seat_limit_reached',
      currentSeats,
      maxSeats: org.maxSeats,
      upgradeUrl: `https://app.example.com/billing/upgrade?org=${orgId}`
    }
  }

  await assignSeat(orgId, userId)
  return { success: true, remainingSeats: org.maxSeats - currentSeats - 1 }
}

Soft Cap

Allow overage with automatic billing. The user gets provisioned immediately, and the additional seat shows up on the next invoice at a premium rate (typically 1.2x–1.5x the per-seat price). This is customer-friendly but requires clear communication — surprise charges erode trust.

// Soft cap with overage billing
const provisionWithOverage = async (orgId: string, userId: string) => {
  const org = await getOrganization(orgId)
  const currentSeats = await getActiveSeatCount(orgId)

  const isOverage = currentSeats >= org.maxSeats

  await assignSeat(orgId, userId)

  if (isOverage) {
    // Record overage for billing
    await recordOverage(orgId, {
      userId,
      overageStartedAt: new Date().toISOString(),
      rateMultiplier: 1.25  // 25% premium on overage seats
    })

    // Notify org admins immediately
    await notifyAdmins(orgId, {
      type: 'seat_overage',
      message: `Seat limit exceeded. User provisioned as overage seat at 1.25x rate.`,
      currentSeats: currentSeats + 1,
      maxSeats: org.maxSeats
    })
  }

  return { success: true, isOverage }
}

Grace Period

The middle ground. Allow overage for a fixed window (7–30 days) at no extra charge. If the customer doesn't resolve it within the window — by removing seats or upgrading — overage billing kicks in or provisioning is blocked. This is the most enterprise-friendly approach because it acknowledges that organizations have approval cycles for budget changes.

StrategyCustomer ExperienceRevenue ImpactImplementation Complexity
Hard CapPoor — blocks workflowsForces upgrades but causes churnLow
Soft CapGood — seamless provisioningCaptures overage revenue automaticallyMedium
Grace PeriodBest — respects procurement cyclesDelayed revenue but higher retentionHigh

Seat Validation with Traffic Orchestrator API

These examples show how to integrate per-seat enforcement into your application using the Traffic Orchestrator API. Each example addresses a real workflow you'll need in production.

Checking Available Seats Before User Creation

Before your app creates a new user, verify that the organization has available seats. This prevents the awkward scenario of creating a user record, sending a welcome email, and then discovering the seat limit is exceeded.

// Check seat availability before user provisioning
const checkAndProvision = async (orgLicenseKey: string, newUser: UserInput) => {
  // Step 1: Validate the license and check seat entitlements
  const validation = await fetch('https://api.trafficorchestrator.com/api/v3/licenses/validate', {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer to_live_key_xxxxxxxxxxxx',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      licenseKey: orgLicenseKey,
      scope: {
        feature: 'seats',
        metadata: {
          action: 'check_availability'
        }
      }
    })
  })

  const result = await validation.json()

  if (!result.valid) {
    return { error: 'invalid_license', details: result.error }
  }

  // Step 2: Check seat capacity from entitlements
  const seatEntitlement = result.entitlements?.maxSeats ?? 0
  const currentUsage = result.usage?.activeSeats ?? 0

  if (currentUsage >= seatEntitlement) {
    return {
      error: 'seats_exhausted',
      currentSeats: currentUsage,
      maxSeats: seatEntitlement,
      suggestion: 'Upgrade plan or remove inactive users'
    }
  }

  // Step 3: Provision the user and record the seat activation
  const user = await createUser(newUser)

  await fetch('https://api.trafficorchestrator.com/api/v3/licenses/activate', {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer to_live_key_xxxxxxxxxxxx',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      licenseKey: orgLicenseKey,
      activationId: user.id,
      label: user.email,
      metadata: {
        type: 'seat',
        provisionedAt: new Date().toISOString()
      }
    })
  })

  return { success: true, userId: user.id, remainingSeats: seatEntitlement - currentUsage - 1 }
}

Webhook Handling for Seat Changes

When seats change outside your application — an admin removes a seat through your dashboard, a SCIM sync fires, or a subscription is downgraded — webhooks keep your app in sync.

// Webhook handler for seat lifecycle events
const handleSeatWebhook = async (request: Request) => {
  const payload = await request.json()
  const signature = request.headers.get('X-TO-Signature')

  // Verify webhook signature to prevent spoofing
  const isValid = await verifyWebhookSignature(payload, signature)
  if (!isValid) {
    return new Response('Invalid signature', { status: 401 })
  }

  switch (payload.event) {
    case 'license.activation.created':
      // New seat provisioned
      await onSeatAdded({
        orgId: payload.data.licenseId,
        userId: payload.data.activationId,
        metadata: payload.data.metadata
      })
      break

    case 'license.activation.deleted':
      // Seat released
      await onSeatRemoved({
        orgId: payload.data.licenseId,
        userId: payload.data.activationId,
        reason: payload.data.metadata?.reason ?? 'manual'
      })
      break

    case 'license.updated':
      // Plan changed — seat limit may have changed
      const newMaxSeats = payload.data.entitlements?.maxSeats
      if (newMaxSeats !== undefined) {
        await onSeatLimitChanged({
          orgId: payload.data.licenseId,
          newMaxSeats,
          previousMaxSeats: payload.data.previousEntitlements?.maxSeats
        })
      }
      break

    case 'license.suspended':
      // License suspended — deactivate all seats
      await onAllSeatsDeactivated({
        orgId: payload.data.licenseId,
        reason: payload.data.suspensionReason
      })
      break
  }

  return new Response('OK', { status: 200 })
}

Real-Time Seat Count Dashboard Query

Admins need visibility into seat utilization. This query powers the dashboard widget showing current usage, trends, and recommendations.

// Fetch seat utilization data for admin dashboard
const getSeatDashboard = async (orgLicenseKey: string, apiKey: string) => {
  // Get current license status with usage details
  const response = await fetch('https://api.trafficorchestrator.com/api/v3/licenses/validate', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      licenseKey: orgLicenseKey,
      include: ['activations', 'usage', 'entitlements']
    })
  })

  const license = await response.json()

  // Get all active seat activations
  const activationsResponse = await fetch(
    `https://api.trafficorchestrator.com/api/v3/licenses/${license.id}/activations`,
    {
      headers: { 'Authorization': `Bearer ${apiKey}` }
    }
  )

  const activations = await activationsResponse.json()

  // Calculate utilization metrics
  const maxSeats = license.entitlements?.maxSeats ?? 0
  const activeSeats = activations.data?.filter(
    (a: Record<string, string>) => a.status === 'active'
  ).length ?? 0
  const utilizationRate = maxSeats > 0 ? (activeSeats / maxSeats) * 100 : 0

  // Identify ghost seats (no activity in 90+ days)
  const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString()
  const ghostSeats = activations.data?.filter(
    (a: Record<string, string>) => a.status === 'active' && a.lastValidatedAt < ninetyDaysAgo
  ) ?? []

  return {
    maxSeats,
    activeSeats,
    availableSeats: maxSeats - activeSeats,
    utilizationRate: Math.round(utilizationRate),
    ghostSeats: ghostSeats.length,
    potentialSavings: ghostSeats.length * license.perSeatPrice,
    activations: activations.data
  }
}

Per-Seat vs Per-Device vs Per-Feature: When to Use Each

Per-seat isn't always the right model. Understanding the alternatives helps you pick the right fit — or combine models for different customer segments.

ModelBest ForRevenue DynamicsExample Products
Per-SeatCollaboration tools, any product where value scales with usersLinear growth with headcountSlack, Jira, Figma
Per-DeviceDesktop apps, embedded systems, IoTTied to hardware deploymentAutoCAD, antivirus, POS systems
Per-FeaturePlatforms with modular capabilitiesExpansion through feature adoptionSalesforce add-ons, AWS services
Usage-BasedAPI platforms, compute, storageScales with consumptionTwilio, Snowflake, Stripe
Hybrid (Seat + Feature)Enterprise platforms with tiered capabilitiesBase revenue + expansionGitHub (seats + Actions minutes)

When Per-Seat Fails

Per-seat is the wrong model when:

  • Usage is highly variable — if one user generates 100x the value of another, flat per-seat pricing leaves money on the table
  • The product is consumed by machines — CI/CD tools, monitoring agents, and API services don't map to human seats
  • Access is occasional — if 80% of users log in less than once a month, per-seat feels expensive and drives churn
  • Value is delivered at the org level — security tools that protect the entire network regardless of how many admins configure them

Hybrid Models That Work

The trend in 2026 is hybrid licensing: per-seat for core access, plus usage or feature layers on top. Some effective combinations:

  • Seat + API calls — base seats for dashboard access, metered billing for API consumption
  • Seat tiers + feature gates — Viewer seats at $5/month, Editor seats at $15/month, Admin seats at $25/month
  • Seat + storage/compute — per-seat for users, usage-based for resources consumed

Enterprise Considerations

Enterprise deals above 500 seats introduce requirements that don't exist at smaller scales. These aren't nice-to-haves — they're procurement blockers.

SSO Seat Sync

Enterprise IT expects seat management to be fully automated through their IdP. This means supporting SCIM 2.0 provisioning, which is more than simple user sync — it needs to handle group-based seat assignment.

// Group-based seat assignment from SCIM
const handleGroupMembership = async (scimPayload: ScimGroupPatch) => {
  const { groupId, operation, members } = scimPayload

  // Map IdP group to seat tier
  const seatTier = await getSeatTierForGroup(groupId)
  // e.g., "Engineering" group -> "Editor" tier
  // "Marketing" group -> "Viewer" tier

  if (operation === 'add') {
    for (const member of members) {
      await provisionSeatWithTier(member.userId, seatTier)
    }
  } else if (operation === 'remove') {
    for (const member of members) {
      await deprovisionSeat(member.userId)
    }
  }
}

Department-Level Seat Pools

Large organizations don't buy a single pool of seats. They allocate budgets per department, and each department manages its own seats. Your licensing system needs to support sub-allocations:

  • Parent organization with a total seat entitlement of 1,000
  • Engineering department allocated 400 seats
  • Sales department allocated 350 seats
  • Unallocated pool of 250 seats available for any department

Each department admin can add/remove seats within their allocation. Only org-level admins can move seats between departments or access the unallocated pool.

Compliance Reporting

Regulated industries (finance, healthcare, government) require audit trails for license usage. Your system must answer questions like:

  • Who had access to the software on a specific date?
  • When was a user provisioned and by whom?
  • What is the complete history of seat assignment changes?
  • Can you prove that seat count never exceeded the contract limit?

This is where the SeatSnapshot table pays for itself. Daily snapshots plus an immutable audit log of every seat change gives compliance teams what they need without manual spreadsheet tracking.

Pricing Strategy for Per-Seat Models

Getting the licensing model right is only half the equation. Pricing determines whether customers adopt, expand, or churn.

Volume Discounts

Tiered pricing that rewards larger commitments. The simplest approach:

Seat RangePer-Seat PriceEffective Discount
1–10$25/month
11–50$20/month20%
51–200$15/month40%
201–500$12/month52%
501+CustomNegotiated

Implementation detail: apply the discount to all seats at the highest tier, not incrementally. If a customer has 60 seats, charge 60 × $15 = $900, not (10 × $25) + (40 × $20) + (10 × $15) = $1,200. Incremental pricing is confusing, error-prone, and makes customers feel like they're being nickel-and-dimed.

Minimum Commits

Require a minimum seat purchase (e.g., 5 seats minimum on Team plan, 25 on Business plan). This filters out single-user signups from enterprise plans and sets a revenue floor per account. Pair this with a generous free tier for individuals to avoid pushing small teams away entirely.

Annual Prepay

Offer a meaningful discount (15–20%) for annual commitment. This is critical for cash flow and dramatically reduces churn — customers who prepay annually churn at 5–8% versus 15–20% for monthly subscribers. The psychology is simple: sunk cost drives engagement.

Structure annual contracts with a high-water-mark billing approach: the customer pays for their maximum seat count during the period. If they start with 50 seats and grow to 80, they pay the prorated difference immediately. If they drop back to 60, they don't get a refund — the contract covers the peak. This is standard in enterprise software and avoids the "gaming the system" problem where customers drop seats before renewal.

Seat Tier Pricing

Not all users need the same capabilities. Differentiated seat pricing lets you capture more of the value spectrum:

  • Viewer/Read-only ($0–$5/month) — stakeholders who need visibility but don't create content. Free or near-free to maximize adoption.
  • Member/Editor ($15–$25/month) — regular users who create and modify content. Your primary revenue driver.
  • Admin/Power User ($25–$50/month) — users with advanced features, API access, or administrative capabilities.

This model works because it aligns price with value delivered. A marketing VP who checks a dashboard weekly shouldn't pay the same as a developer who lives in the platform 8 hours a day.

Implementation Checklist

Before shipping per-seat licensing, verify you've addressed each of these:

  • Seat counting excludes service accounts and bots — automated users shouldn't consume human seats
  • Invitation flow reserves seats — pending invites should count against the limit to prevent over-provisioning
  • SCIM/SSO deprovisioning actually releases seats — test the full lifecycle, not just provisioning
  • Ghost seat detection runs automatically — weekly reports to org admins with actionable data
  • Overage policy is documented and communicated — customers should never be surprised by what happens at the limit
  • Audit trail captures every seat change — who, when, why, and how for compliance
  • Billing reconciliation handles mid-cycle changes — prorated additions and removals calculated correctly
  • API rate limits are per-seat-aware — a 10-seat org shouldn't get the same API rate limits as a 500-seat org
  • Downgrade path is clear — what happens when a customer reduces seats? Which users get deprovisioned?
  • Multi-org users are handled — one person, multiple organizations, each consuming a seat independently

Per-Seat Licensing, Handled by the API

Traffic Orchestrator provides seat-based entitlements, real-time activation tracking, SCIM-ready provisioning webhooks, and overage controls — all through a single API with sub-10ms validation at the edge.

See Plans
TOT
Traffic Orchestrator Team
Product Engineering

The engineering team behind Traffic Orchestrator, building enterprise-grade software licensing infrastructure used by developers worldwide.

Was this article helpful?
Get licensing insights delivered

Engineering deep-dives, security advisories, and product updates. Unsubscribe anytime.

Share this article
Free Plan Available

Ship licensing in your next release

5 licenses, 500 validations/month, full API access. Set up in under 5 minutes — no credit card required.

2-minute setup No credit card Cancel anytime