Skip to content

Usage Cap Enforcement


Design Principle

All 6 stage types are available on every plan. There is no feature gate on stage types. The only monetization lever is volume-based usage limits, stored in Organization.featureSnapshot.

This means:

  • GET /v1/stage-types always returns all 6 stage types — no plan filtering
  • The frontend never shows "upgrade to unlock" overlays on stage type options
  • A free plan user can use live_1on1 or technical_dsa — they just have lower volume limits

The Three Usage Limits

LimitChecked atError
maxActiveJobsPOST /v1/jobs403: Active job limit reached for your plan
maxCandidatesPerJobPOST /v1/interviews (first invite for a new candidate)403: Candidate limit per job reached for your plan
maxInterviewsPerMonthPOST /v1/interviews403: Monthly interview limit reached for your plan

-1 means unlimited (used on pro and enterprise plans).


Enforcement Code

typescript
// POST /v1/jobs — active job limit
async function checkActiveJobLimit(organizationId: ObjectId, featureSnapshot: IFeatureSnapshot) {
  if (featureSnapshot.maxActiveJobs === -1) return; // unlimited
  const count = await JobOpening.countDocuments({
    organizationId,
    status: { $in: ['open', 'draft'] },
    isDeleted: false,
  });
  if (count >= featureSnapshot.maxActiveJobs) {
    throw new HttpError(403, 'Active job limit reached for your plan');
  }
}

// POST /v1/interviews — monthly interview count
async function checkInterviewLimit(organizationId: ObjectId, featureSnapshot: IFeatureSnapshot) {
  if (featureSnapshot.maxInterviewsPerMonth === -1) return;
  const startOfMonth = new Date();
  startOfMonth.setDate(1);
  startOfMonth.setHours(0, 0, 0, 0);
  const count = await Interview.countDocuments({
    organizationId,
    createdAt: { $gte: startOfMonth },
  });
  if (count >= featureSnapshot.maxInterviewsPerMonth) {
    throw new HttpError(403, 'Monthly interview limit reached for your plan');
  }
}

// POST /v1/interviews — first invite for a candidate to a job
async function checkCandidateLimit(jobOpeningId: ObjectId, featureSnapshot: IFeatureSnapshot) {
  if (featureSnapshot.maxCandidatesPerJob === -1) return;
  const existing = await CandidatePipeline.findOne({ jobOpeningId, participantId });
  if (existing) return; // Re-invite — not a new candidate, don't count
  const count = await CandidatePipeline.countDocuments({ jobOpeningId });
  if (count >= featureSnapshot.maxCandidatesPerJob) {
    throw new HttpError(403, 'Candidate limit per job reached for your plan');
  }
}

Premium Feature Gates (Phase 5+)

In addition to volume limits, some features require premium plan flags:

FeatureRequired flag
Analytics endpoints (/v1/analytics/*)featureSnapshot.advancedAnalytics === true
Custom branding on candidate pagesfeatureSnapshot.customBranding === true
API access tierfeatureSnapshot.apiAccess === true

Frontend checks featureSnapshot.advancedAnalytics to show an upgrade prompt on the analytics page instead of the actual charts.


Plan Upgrade Impact

When a plan is upgraded (via Stripe or manual update):

  1. Organization.planKey updated
  2. Organization.featureSnapshot re-synced from new Plan.features
  3. Higher limits take effect immediately on the next API call

There is no cache to invalidate — featureSnapshot is always the current source of truth for usage checks.


featureSnapshot vs. Plan Join

featureSnapshot is a denormalized copy of the plan limits stored directly on the Organization document. This avoids a join to the Plan collection on every API request.

The alternative — always joining to Plan — would work, but featureSnapshot reduces MongoDB reads on high-traffic endpoints like POST /v1/interviews.

The trade-off: featureSnapshot can become stale if Plan documents are edited directly without going through the plan-change flow. Always use POST /v1/orgs/:id/plan to change plans — never edit Plan documents and expect featureSnapshot to auto-update.