API Reference
Base URL: VITE_SERVER_URL/v1
All protected endpoints require: Authorization: Bearer <Firebase idToken>
Stage Types
GET /v1/stage-types
Returns all active stage types. No auth required. No plan filtering — all 6 types returned on every plan.
Response: IStageTypeConfig[] sorted by order
Jobs
GET /v1/jobs
List jobs for the authenticated recruiter's organization.
Query params: status, page, limit
GET /v1/jobs/:id
Job detail with stages[] populated (question refs not populated).
POST /v1/jobs
Create a job.
Body:
{
title: string;
description: string;
department?: string;
location?: string;
employmentType?: 'full_time' | 'part_time' | 'contract' | 'internship';
stages: {
name: string;
stageTypeKey: StageTypeKey;
screeningConfig?: { questions: { text: string; order: number }[]; ... };
dsaConfig?: { problems: { problemId: string; order: number }[]; ... };
live1on1Config?: { privateQuestions: { questionId: string; order: number }[]; ... };
// ...
}[];
}Errors:
400— Zod validation failure403—maxActiveJobslimit reached
PATCH /v1/jobs/:id
Update job. (Note: syncActivePipelines() — syncing active pipeline stage names on job edit — is deferred and not yet implemented.)
DELETE /v1/jobs/:id
Soft-delete. Sets isDeleted: true, status: 'archived'.
Interviews
POST /v1/interviews
Schedule an interview (invite a candidate to a stage).
Two request formats depending on stage type:
Format A — pipeline-aware automated screening (stageId + isAutomated: true or omitted):
{
jobOpeningId: string;
stageId: string;
stageIndex: number;
participant: { email: string; name?: string };
candidatePipelineId?: string;
clientUrl: string;
sendAutomatedLink?: boolean;
}Format B — pipeline-aware live interview (stageId + isAutomated: false):
{
jobOpeningId: string;
title: string;
round: string;
startTime: string; // ISO datetime, required for live
endTime: string; // ISO datetime, required for live
baseUrl: string;
clientUrl: string;
participant: { email: string; name?: string };
isAutomated: false;
stageId: string;
stageIndex: number;
candidatePipelineId?: string;
recruiterName?: string;
recruiterEmail?: string;
}Format C — legacy live interview (no stageId): same as B without stageId/stageIndex/candidatePipelineId.
Notes:
- Format B stores
candidatePipelineId+stageIdon Interview, setsmeetingLink = ${baseUrl}/room/${interview._id}, callsmarkStageInvited() - Format C uses
${baseUrl}/lobby/${nanoid(10)}for meetingLink (no pipeline awareness)
Errors:
403— usage limit exceeded (maxInterviewsPerMonth)409— stage status guard: stage ispending(locked, must unlock first), OR alreadyinvited/in_progress/completed, OR an active interview for(candidatePipelineId, stageId)already exists
GET /v1/interviews/:id
Interview detail (recruiter view — all fields including internal). Returns feedbacks[] and candidateFeedback.
POST /v1/interviews/:id/feedback
Submit recruiter feedback. Auth required. Required before unlock-stage for live_1on1 and culture_fit_hr stages.
Body:
{
interviewerEmail: string;
overallRating: number; // 1–10 (integer)
traits: string[]; // selected trait chips
recommendation: 'strong_yes' | 'yes' | 'no' | 'strong_no';
comments: string; // min 5 chars
}Side effects:
- Sets
interview.status = 'completed', capturesendTime - If
candidatePipelineId+stageIdpresent: callsmarkStageCompleted()withresultderived fromrecommendation(strong_yes | yes → 'pass', else'hold'),candidateStatus = 'completed'
POST /v1/interviews/:id/candidate-feedback
Submit candidate's post-call feedback. Auth required. Does NOT affect pipeline or interview status.
Body:
{
overallRating: number; // 1–10 (integer)
traits: string[];
recommendation: 'strong_yes' | 'yes' | 'no' | 'strong_no';
comments?: string; // optional
}Stored in interview.candidateFeedback (single sub-doc, not in feedbacks[]).
GET /v1/interviews/:id/private-questions (Phase 1C)
Returns private question list for live session. Auth: recruiter must be in interview.interviewers.
POST /v1/interviews/decline/:declineToken
No auth required. Registered before /:id routes to prevent param collision.
Body (optional): { reason?: string, tags?: string[] }
Finds interview by declineToken (no status filter). Saves stageData.declineData, marks interview declined, updates pipeline, and queues recruiter notification email.
Screening Sessions (No Auth)
GET /v1/screening/:token
Validate token, return session config + questions (no AI scores, no internal fields).
POST /v1/screening/:token/start
Mark interview in_progress, update pipeline stage status.
POST /v1/screening/:token/submit
Submit responses. Triggers async AI evaluation.
Pipeline
GET /v1/pipeline
Query: ?jobId=:id — List all pipelines for a job.
GET /v1/pipeline/:id
Full pipeline detail. Populates all stageProgression[].interviewId refs.
PATCH /v1/pipeline/:id/status
Set global pipeline status.
Body: { status: 'shortlisted' | 'rejected' | 'hired' | 'active' | 'withdrawn' }
POST /v1/pipeline/:id/unlock-stage
Unlock the next stage. Supports a force flag to cascade-complete preceding incomplete stages.
Body: { stageIndex: number, force?: boolean }
Errors:
400 requiresFeedback: true— a precedinglive_1on1orculture_fit_hrstage exists without recruiter feedback.force: truecannot bypass this guard. Response includespendingStages: string[].409 requiresForce: true— preceding stages not yet completed. Useforce: trueto cascade-complete them (frontend shows confirmation dialog first). Note: this guard is skipped if therequiresFeedbackguard fires first.
POST /v1/pipeline/:id/notes
Add internal recruiter note.
GET /v1/pipeline/pool
Aggregated list of unique candidates across all jobs for the org.
Registered before
/:idto avoid param collision.
Query params:
| Param | Type | Default | Notes |
|---|---|---|---|
search | string | — | Regex match on name + email (min 2 chars) |
status | enum | — | active | shortlisted | rejected | withdrawn | hired |
sort | enum | lastActivity | lastActivity | name | jobCount |
page | number | 1 | |
limit | number | 20 | max 100 |
Response:
{
"success": true,
"data": {
"candidates": [
{
"participantId": "...",
"name": "Jane Smith",
"email": "jane@example.com",
"jobCount": 3,
"latestStatus": "active",
"lastActivityAt": "2026-03-01T..."
}
],
"pagination": { "total": 42, "page": 1, "limit": 20, "totalPages": 3 },
"summary": { "total": 42, "active": 28, "hired": 5, "shortlisted": 9 }
}
}Aggregation stages: $match (org) → $sort (lastActivityAt) → $lookup (Participant) → $unwind → optional search $match → $group by participantId → $facet { data, total, summary }.
The summary facet runs before the status filter, so stat card counts always reflect search-filtered unique candidates regardless of the status dropdown.
GET /v1/pipeline/pool/:participantId
All pipelines for a single participant within the org, with participant detail.
Registered before
/:id.
Response:
{
"success": true,
"data": {
"participant": { "_id": "...", "name": "Jane", "email": "jane@example.com", "stats": { ... } },
"pipelines": [ /* full CandidatePipeline docs, sorted by lastActivityAt desc */ ]
}
}Candidate Dashboard (/v1/candidate/*)
All require candidate Firebase JWT.
GET /v1/candidate/dashboard
All pipelines for the authenticated candidate, sorted by lastActivityAt desc. Returns candidateFacingStatus and stageProgression[].candidateStatus only (projected).
GET /v1/candidate/pipeline/:id
Full pipeline detail — projected. Verifies pipeline.participantId matches req.participant._id.
GET /v1/candidate/interviews/:id
Past interview session — projected. Own answers only; no AI scores, no feedback.
PATCH /v1/candidate/interviews/:id/rsvp
Accept or decline a live interview invite.
Body: { status: 'accepted' | 'declined' }
PATCH /v1/candidate/preferences
Update candidate notification preferences.
Question Banks
GET /v1/screening-questions
Query: ?source=system&category=...
POST /v1/screening-questions
Create a custom question for the org.
GET /v1/interview-questions (Phase 1C)
Query: ?questionType=behavioral&applicableStageTypes=live_1on1
POST /v1/interview-questions (Phase 1C)
GET /v1/dsa-problems (Phase 3)
Query: ?difficulty=medium&tags=array
POST /v1/dsa-problems (Phase 3)
GET /v1/dsa-problems/:id (Phase 3)
Returns problem without hidden test cases.
GET /v1/scenario-questions (Phase 4)
Query: ?applicableStageTypes=ai_conversational&scenarioType=behavioral
POST /v1/scenario-questions (Phase 4)
DSA Sessions (Phase 3, No Auth)
GET /v1/dsa/:token
Load problem list, starter code, time limits.
POST /v1/dsa/:token/run
Run code against visible test cases.
POST /v1/dsa/:token/submit
Final submission — run against hidden test cases, store results.
Organization Management (Phase 5)
GET /v1/orgs/:id
PATCH /v1/orgs/:id
GET /v1/orgs/:id/members
POST /v1/orgs/:id/members/invite
PATCH /v1/orgs/:id/members/:userId
DELETE /v1/orgs/:id/members/:userId
GET /v1/orgs/:id/features
POST /v1/orgs/:id/plan
GET /v1/plans (public, no auth)
Analytics (Phase 6, gated by advancedAnalytics)
GET /v1/analytics/pipeline-funnel?jobId=:id
GET /v1/analytics/time-to-hire?jobId=:id
GET /v1/analytics/question-effectiveness
GET /v1/analytics/interviewer-activity
Webhooks
POST /v1/webhooks/stripe (Phase 5)
Handles customer.subscription.updated and customer.subscription.deleted.
Health
GET /health
Returns { status: 'ok', db: 'connected', queue: { depth: n } } (Phase 6 adds queue depth)