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-typesalways returns all 6 stage types — no plan filtering- The frontend never shows "upgrade to unlock" overlays on stage type options
- A
freeplan user can uselive_1on1ortechnical_dsa— they just have lower volume limits
The Three Usage Limits
| Limit | Checked at | Error |
|---|---|---|
maxActiveJobs | POST /v1/jobs | 403: Active job limit reached for your plan |
maxCandidatesPerJob | POST /v1/interviews (first invite for a new candidate) | 403: Candidate limit per job reached for your plan |
maxInterviewsPerMonth | POST /v1/interviews | 403: Monthly interview limit reached for your plan |
-1 means unlimited (used on pro and enterprise plans).
Enforcement Code
// 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:
| Feature | Required flag |
|---|---|
Analytics endpoints (/v1/analytics/*) | featureSnapshot.advancedAnalytics === true |
| Custom branding on candidate pages | featureSnapshot.customBranding === true |
| API access tier | featureSnapshot.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):
Organization.planKeyupdatedOrganization.featureSnapshotre-synced from newPlan.features- 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.